chizu
v0.2.46
Published
<div align="center"> <img src="/media/logo-v2.png" width="475" />
Readme
Strongly typed React framework using generators and efficiently updated views alongside the publish-subscribe pattern.
Contents
For advanced topics, see the recipes directory.
Benefits
- Event-driven architecture superset of React.
- Views only re-render when the model changes.
- Built-in optimistic updates via Immertation.
- No stale closures –
context.datastays current afterawait. - No need to lift state – siblings communicate via events.
- Reduces context proliferation – events replace many contexts.
- No need to memoize callbacks – handlers are stable via
useEffectEvent. - Clear separation between business logic and markup.
- Complements Feature Slice Design architecture.
- Strongly typed dispatches, models, payloads, etc.
- Built-in request cancellation with
AbortController. - Granular async state tracking per model field.
- Declarative lifecycle hooks without
useEffect. - Centralised error handling via the
Errorcomponent. - React Native compatible – uses eventemitter3 for cross-platform pub/sub.
Getting started
We dispatch the Actions.Name event upon clicking the "Sign in" button and within useNameActions we subscribe to that same event so that when it's triggered it updates the model with the payload – in the React component we render model.name. The With helper binds the action's payload directly to a model property.
import { useActions, Action, With } from "chizu";
type Model = {
name: string | null;
};
const model: Model = {
name: null,
};
export class Actions {
static Name = Action<string>("Name");
}
export default function useNameActions() {
const actions = useActions<Model, typeof Actions>(model);
actions.useAction(Actions.Name, With("name"));
return actions;
}export default function Profile(): React.ReactElement {
const [model, actions] = useNameActions();
return (
<>
<p>Hey {model.name}</p>
<button onClick={() => actions.dispatch(Actions.Name, randomName())}>
Sign in
</button>
</>
);
}When you need to do more than just assign the payload – such as making an API request – expand useAction to a full function. It can be synchronous, asynchronous, or even a generator:
actions.useAction(Actions.Name, async (context) => {
context.actions.produce((draft) => {
draft.model.name = context.actions.annotate(Op.Update, null);
});
const name = await fetch(api.user());
context.actions.produce((draft) => {
draft.model.name = name;
});
});Notice we're using annotate which you can read more about in the Immertation documentation. Nevertheless once the request is finished we update the model again with the name fetched from the response and update our React component again.
If you need to access external reactive values (like props or useState from parent components) that always reflect the latest value even after await operations, pass a data callback to useActions:
const actions = useActions<Model, typeof Actions, { query: string }>(
model,
() => ({ query: props.query }),
);
actions.useAction(Actions.Search, async (context) => {
await fetch("/search");
// context.data.query is always the latest value
console.log(context.data.query);
});For more details, see the referential equality recipe.
Each action should be responsible for managing its own data – in this case our Profile action handles fetching the user but other components may want to consume it – for that we should use a distributed action:
class DistributedActions {
static Name = Action<string>("Name", Distribution.Broadcast);
}
class Actions extends DistributedActions {
static Profile = Action<string>("Profile");
}actions.useAction(Actions.Profile, async (context) => {
context.actions.produce((draft) => {
draft.model.name = context.actions.annotate(Op.Update, null);
});
const name = await fetch(api.user());
context.actions.produce((draft) => {
draft.model.name = name;
});
context.actions.dispatch(Actions.Name, name);
});Once we have the distributed action if we simply want to read the name when it's updated we can use consume:
export default function Subscriptions(): React.ReactElement {
return (
<>
Manage your subscriptions for your{" "}
{actions.consume(Actions.Name, (name) => name.value)} account.
</>
);
}However if we want to listen for it and perform another operation in our local component we can do that via useAction:
actions.useAction(Actions.Name, async (context, name) => {
const friends = await fetch(api.friends(name));
context.actions.produce((draft) => {
draft.model.friends = friends;
});
});For targeted event delivery, use channeled actions. Define a channel type as the second generic argument and call the action with a channel object – handlers fire when the dispatch channel matches:
class Actions {
// Second generic arg defines the channel type
static UserUpdated = Action<User, { UserId: number }>("UserUpdated");
}
// Subscribe to updates for a specific user
actions.useAction(
Actions.UserUpdated({ UserId: props.userId }),
(context, user) => {
// Only fires when dispatched with matching UserId
},
);
// Subscribe to all admin user updates
actions.useAction(
Actions.UserUpdated({ Role: Role.Admin }),
(context, user) => {
// Fires for {Role: Role.Admin}, {Role: Role.Admin, UserId: 5}, etc.
},
);
// Dispatch to specific user
actions.dispatch(Actions.UserUpdated({ UserId: user.id }), user);
// Dispatch to all admin handlers
actions.dispatch(Actions.UserUpdated({ Role: Role.Admin }), user);
// Dispatch to plain action - ALL handlers fire (plain + all channeled)
actions.dispatch(Actions.UserUpdated, user);Channel values support non-nullable primitives: string, number, boolean, or symbol. By convention, use uppercase keys like {UserId: 4} to distinguish channel keys from payload properties.
