/maˈɾe.a/
Strongly typed web component library using generators and efficiently updated views alongside the publish-subscribe pattern.
- 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.
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…</p>
)}
<button
disabled={self.validate.name.is(State.Updating)}
onClick={() => self.actions.dispatch([Events.Name])}
>
Switch profile
</button>
</>
);
});
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);
});
},
};
});