Strongly typed React framework using generators and efficiently updated views alongside the publish-subscribe pattern.
For advanced topics, see the recipes directory.
- 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 references with fresh closure access.
- 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.
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.
Both the model and actions type parameters default to void, so you can call useActions() with no generics at all when neither is needed:
import { useActions, Lifecycle } from "chizu";
class Actions {
static Mount = Lifecycle.Mount();
}
const actions = useActions<void, typeof Actions>();
actions.useAction(Actions.Mount, () => {
console.log("Mounted!");
});If your component doesn't need local state but still needs to dispatch or listen to typed actions, pass void as the model type. No initial model is required:
import { useActions, Action, Lifecycle } from "chizu";
export class Actions {
static Ping = Action("Ping");
}
export default function usePingActions() {
const actions = useActions<void, typeof Actions>();
actions.useAction(Actions.Ping, () => {
console.log("Pinged!");
});
return actions;
}This is useful for components that only coordinate via events – forwarding broadcasts, triggering side-effects, or bridging external systems. You can still use lifecycle hooks, context.data, and dispatch as normal. See the void model recipe for more details.
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 broadcast action:
class BroadcastActions {
static Name = Action<string>("Name", Distribution.Broadcast);
}
class Actions {
static Broadcast = BroadcastActions;
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.Broadcast.Name, name);
});Once we have the broadcast action, if we want to listen for it and perform another operation in our local component we can do that via useAction:
actions.useAction(Actions.Broadcast.Name, async (context, name) => {
const friends = await fetch(api.friends(name));
context.actions.produce((draft) => {
draft.model.friends = friends;
});
});Both read and peek access the latest cached broadcast value without subscribing via useAction. The difference is that read waits for any pending annotations on the corresponding model field to settle before resolving, whereas peek returns the value immediately:
actions.useAction(Actions.FetchFriends, async (context) => {
const name = await context.actions.read(Actions.Broadcast.Name);
if (!name) return;
const friends = await fetch(api.friends(name));
context.actions.produce(({ model }) => {
model.friends = friends;
});
});peek is useful for guard checks or synchronous reads where you don't need to wait for settled state:
actions.useAction(Actions.Check, (context) => {
const name = context.actions.peek(Actions.Broadcast.Name);
if (!name) return;
console.log(name);
});Dispatch is awaitable – context.actions.dispatch returns a Promise<void> that resolves when all triggered handlers have completed. This prevents UI flashes where local state changes before upstream handlers finish:
actions.useAction(Actions.Mount, async (context) => {
// Wait for all PaymentSent handlers across the app to finish.
await context.actions.dispatch(Actions.Broadcast.PaymentSent);
// Safe to update local state now — upstream work is done.
context.actions.produce(({ model }) => {
model.loading = false;
});
});Generator handlers are excluded from the await — they run in the background and do not block the dispatch promise, since they are typically long-lived (polling, SSE streams, etc.).
You can also render broadcast values declaratively in JSX with actions.stream. The renderer callback receives (value, inspect) and returns React nodes:
function Dashboard() {
const [model, actions] = useDashboardActions();
return (
<div>
{actions.stream(Actions.Broadcast.User, (user, inspect) => (
<span>Welcome, {user.name}</span>
))}
</div>
);
}Components that mount after a broadcast has already been dispatched automatically receive the cached value via their useAction handler. If you also fetch data in Lifecycle.Mount(), see the mount deduplication recipe to avoid duplicate requests.
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.
For scoped communication between component groups, use multicast actions with the <Scope> component:
import { Action, Distribution, Scope } from "chizu";
// Shared multicast actions
class MulticastActions {
static Update = Action<number>("Update", Distribution.Multicast);
}
// Component-level actions reference shared multicast
class Actions {
static Multicast = MulticastActions;
static Increment = Action("Increment");
}
function App() {
return (
<>
<Scope name="TeamA">
<ScoreBoard />
<PlayerList />
</Scope>
<Scope name="TeamB">
<ScoreBoard />
<PlayerList />
</Scope>
</>
);
}
// Dispatch to all components within "TeamA" scope
actions.dispatch(Actions.Multicast.Update, 42, { scope: "TeamA" });Unlike broadcast which reaches all components, multicast is scoped to the named boundary – perfect for isolated widget groups, form sections, or distinct UI regions. Like broadcast, multicast caches dispatched values per scope – components that mount later automatically receive the cached value. See the mount deduplication recipe if you also fetch data in Lifecycle.Mount().
For components that always render inside a scope, use the withScope HOC to eliminate the manual <Scope> wrapper:
import { withScope } from "chizu";
export default withScope("payment-link", function Layout(): ReactElement {
return (
<div>
<PaymentLink />
<Outlet />
</div>
);
});See the multicast recipe for more details.
For data that is expensive to fetch, use cacheable to cache values with a TTL. Define typed cache entries with Entry and call context.actions.cacheable inside a handler – the callback only runs when the cache is empty or expired:
import { Entry, useActions, Action } from "chizu";
import { O } from "@mobily/ts-belt";
class CacheStore {
static Pairs = Entry<CryptoPair[]>();
static User = Entry<User, { UserId: number }>();
}
class Actions {
static FetchPairs = Action("FetchPairs");
static FetchUser = Action("FetchUser");
}actions.useAction(Actions.FetchPairs, async (context) => {
const { data } = await context.actions.cacheable(
CacheStore.Pairs,
30_000,
async () => O.Some(await api.fetchPairs()),
);
if (data) {
context.actions.produce(({ model }) => {
model.pairs = data;
});
}
});
// Channeled – independent cache per user
actions.useAction(Actions.FetchUser, async (context) => {
const { data } = await context.actions.cacheable(
CacheStore.User({ UserId: context.data.userId }),
60_000,
async () => O.Some(await api.fetchUser(context.data.userId)),
);
if (data) {
context.actions.produce(({ model }) => {
model.user = data;
});
}
});Only Some / Ok values are stored in the cache. None and Error results are skipped. Use context.actions.invalidate to clear a specific entry so the next cacheable call fetches fresh data:
context.actions.invalidate(CacheStore.Pairs);
context.actions.invalidate(CacheStore.User({ UserId: 5 }));The cache is scoped to the nearest <Boundary>. See the caching recipe for more details.
The action regulator lets handlers control which actions may be dispatched across all components within a <Boundary>. Use context.regulator to block or allow actions:
actions.useAction(Actions.Checkout, async (context) => {
// Allow only the cancel action during checkout
context.regulator.allow(Actions.Cancel);
try {
await processPayment(context.task.controller.signal);
} finally {
context.regulator.allow();
}
});disallow() blocks all, disallow(A, B) blocks specific actions, allow() allows all (reset), and allow(A, B) allows only those actions. Each call replaces the previous policy (last-write-wins). Blocked actions fire Reason.Disallowed through the error system without allocating resources. See the action regulator recipe for more details.
Toggling boolean UI state – modals, sidebars, drawers – is one of the most common patterns. Instead of defining actions and handlers, use the actions.features methods:
import { useActions } from "chizu";
import type { Meta } from "chizu";
type F = {
paymentDialog: boolean;
sidebar: boolean;
};
type Model = {
name: string;
meta: Meta.Features<F>;
};
const [model, actions] = useFeatureActions();
// Mutate via actions.features
actions.features.invert("paymentDialog");
actions.features.on("paymentDialog");
actions.features.off("paymentDialog");
// Read from model
{
model.meta.features.paymentDialog && <PaymentDialog />;
}The methods also work inside action handlers via context.actions.features. See the feature toggles recipe for more details.
