Skip to content

Strongly typed web component library using generators and efficiently updated views alongside the publish-subscribe pattern.

Notifications You must be signed in to change notification settings

Wildhoney/Marea

Repository files navigation

Marea

/maˈɾe.a/

Strongly typed web component library using generators and efficiently updated views alongside the publish-subscribe pattern.

Contents

  1. Benefits
  2. Getting started
  3. Handling errors

Benefits

  • Thoughtful event-driven architecture superset of React.
  • Super efficient with views only re-rendering when absolutely necessary.
  • Built-in support for optimistic updates within components.
  • Mostly standard JavaScript without quirky rules and exceptions.
  • Clear separation of concerns between business logic and markup.
  • First-class support for skeleton loading using generators..
  • Strongly typed throughout – styles, controllers and views.
  • Avoid vendor lock-in with framework agnostic libraries such as Shoelace.
  • Easily communicate between controllers using distributed actions.
  • State is mutated sequentially (FIFO) and deeply merged for queued mutations.

Getting started

Controllers are responsible for mutating the state of the view. In the below example the name is dispatched from the view to the controller, the state is updated and the view is rendered once with the updated value.

Controller

export default create.controller<Module>((self) => {
  return {
    *[Events.Name](name) {
      return self.actions.produce((draft) => {
        draft.name = name;
      });
    },
  };
});

View

export default create.view<Module>((self) => {
  return (
    <>
      <p>Hey {self.model.name}</p>

      <button
        onClick={() => self.actions.dispatch([Events.Name, randomName()])}
      >
        Switch profile
      </button>
    </>
  );
});

Fetching the name from an external source using an actions.io causes the controller event (Events.Name) and associated view to be invoked twice – once with a record of mutations to display a pending state, and then again with the model once it's been mutated.

Controller

export default create.controller<Module>((self) => {
  return {
    *[Events.Name]() {
      yield self.actions.io(async () => {
        const name = await fetch(/* ... */);

        return self.actions.produce((draft) => {
          draft.name = name;
        });
      });

      return self.actions.produce((draft) => {
        draft.name = null;
      });
    },
  };
});

View

export default create.view<Module>((self) => {
  return (
    <>
      <p>Hey {self.model.name}</p>

      <button onClick={() => self.actions.dispatch([Events.Name])}>
        Switch profile
      </button>
    </>
  );
});

As the event is invoked twice, it's important they are idempotent – by encapsulating your side effects in actions.io the promises are resolved before invoking the event again with those resolved values.

In the above example the name is fetched asynchronously – however there is no feedback to the user, we can improve that by using the self.actions.placeholder and self.validate helpers:

Controller

export default create.controller<Module>((self) => {
  return {
    *[Events.Name]() {
      yield self.actions.io(async () => {
        const name = await fetch(/* ... */);

        return self.actions.produce((draft) => {
          draft.name = name;
        });
      });

      return self.actions.produce((draft) => {
        draft.name = self.actions.pending(null, State.Updating);
      });
    },
  };
});

View

export default create.view<Module>((self) => {
  return (
    <>
      <p>Hey {self.model.name}</p>

      {self.validate.name.is(State.Pending) && (
        <p>Switching profiles&hellip;</p>
      )}

      <button
        disabled={self.validate.name.is(State.Updating)}
        onClick={() => self.actions.dispatch([Events.Name])}
      >
        Switch profile
      </button>
    </>
  );
});

Handling Errors

Controller actions can throw errors directly or in any of their associated yield actions – all unhandled errors are automatically caught and added to the model.errors vector – you can render these in a toast or similar UI.

You can also customise these errors a little further with your own error enum which describes the error type:

Types

export const enum Errors {
  UserValidation,
  IncorrectPassword,
}

Controller

export default create.controller<Module>((self) => {
  return {
    *[Events.Name]() {
      yield self.actions.io(async () => {
        const name = await fetch(/* ... */);

        if (!name) throw new IoError(Errors.UserValidation);

        return self.actions.produce((draft) => {
          draft.name = name;
        });
      });

      return self.actions.produce((draft) => {
        draft.name = null;
      });
    },
  };
});

However showing a toast message is not always relevant, you may want a more detailed error message such as a user not found message – although you could introduce another property for such errors in your model, you could mark the property as fallible by giving it a Maybe type because it then keeps everything nicely associated with the name property rather than creating another property:

Controller

export default create.controller<Module>((self) => {
  return {
    *[Events.Name]() {
      yield self.actions.io(async () => {
        const name = await fetch(/* ... */);

        if (!name)
          return self.actions.produce((draft) => {
            draft.name = Maybe.Fault(new IoError(Errors.UserValidation));
          });

        return self.actions.produce((draft) => {
          draft.name = Maybe.Present(name);
        });
      });

      return self.actions.produce((draft) => {
        draft.name = Maybe.Present(null);
      });
    },
  };
});

About

Strongly typed web component library using generators and efficiently updated views alongside the publish-subscribe pattern.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages