Type-First React State Tree
React makes local state easy to start with. That is one of its strengths.
The problem is that product state rarely stays local.
A flow starts as a button and a loading flag. Then it gets an optimistic update. Then there is an error path. Then retry needs access to the original input. Then another component renders only during streaming. Then a second feature needs to know whether the user is signed in, whether a draft exists, whether a background request is still resolving, and whether the current screen is allowed to dispatch a specific action.
Eventually the hard part is not storing values.
The hard part is knowing which values exist in which product state, which actions are legal from that state, and which components are allowed to assume which contract.
That is the problem behind Type-First React State Tree.
The idea is to model UI behavior as an explicit state tree where each slice declares its context, actions, sub-slices, entry behavior, and transition rules. Components bind to a slice of that model. If the product flow changes, TypeScript should point at the code that no longer matches the contract.
The Problem With Implicit UI State
For example, a chat or generation workflow might have states like:
- composing an input
- sending a request
- streaming a response
- showing an error
- retrying from the failed input
- preserving the previous messages
- resetting into a new conversation
It is common to represent that with a collection of booleans and nullable fields:
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [draft, setDraft] = useState("");
const [responseBuffer, setResponseBuffer] = useState("");
const [messages, setMessages] = useState<Message[]>([]);This works until the relationships matter more than the values.
Can the retry button appear while streaming? Does responseBuffer exist during compose? Is error cleared when the user sends a new message? Is the component rendering a stream allowed to call retry, or is retry only valid from the error state? Which fields need to be supplied when moving from compose to stream?
Those are product rules, but in many apps they are expressed indirectly through component structure, naming conventions, and hope.
Type-First React State Tree is an attempt to put those rules into a typed model.
The Model Comes First
Instead of discovering UI behavior by jumping through hooks, reducers, effects, and component props, the product flow starts as a compact tree:
type ChatModel = $.Model<
$.Store<{
Compose: $.Slice<
[
$.Context<{ list: Message[]; draft: string }>,
$.Action<{ updateDraft: string; sendMessage: void }>,
]
>;
Generate: $.Slice<
[
$.Context<{
list: Message[];
newMessage: Message;
responseBuffer: string;
}>,
$.SubSlice<{
Stream: $.Slice<[$.OnEntry]>;
Error: $.Slice<[$.Action<{ retry: void }>]>;
}>,
]
>;
}>,
"Compose"
>;The syntax is intentionally type-heavy because the model is not just documentation. It is the source of the contracts that flow into React components.
From this model, a few rules become explicit:
Composehas adraftand can update or send it.Generatehas anewMessageandresponseBuffer.Generate.Streamruns logic when entered.Generate.Errorcan retry.- A component bound to
Generate.Streamshould not receive a retry action unless the model says that action exists there.
The model is not trying to replace product thinking. It is trying to make product thinking harder to lose.
Components Bind to Slices
const { context$, action } = useStore(Chat.slice("Generate.Error"));That slice path is a contract.
The component receives only the context and actions available in that slice. If Generate.Error exposes retry, the component can call action.retry(). If Generate.Stream does not expose retry, the stream component cannot accidentally call it.
That is the part I care about most: the UI can be organized around product states instead of generic access to a global bag of data.
The component says, "I am the error view for the generation flow." The type system says, "Then this is the data and these are the actions you get."
That is a much stronger contract than passing props through a tree and hoping the receiving component is only used in the right place.
Runtime Contracts Still Matter
A component can be imported in the wrong place. A route can render the wrong subtree. A refactor can accidentally show a component under a state it was not designed for.
So the hook performs runtime slice validation. If a component asks for Bar.Qux while the current slice is Foo, it throws:
Component rendered in wrong slice: 'Foo' does not match selected path 'Bar.Qux'That may sound strict, but strictness is the point. A component that assumes Generate.Stream context should not quietly render during Compose. If the product flow says the stream view only exists during streaming, the runtime should enforce that contract during development.
The best developer experience is not only autocomplete. It is fast, specific feedback when a product assumption is wrong.
Transitions Should Know What Changed
When moving from Compose to Generate.Stream, the target state may need a newMessage and an empty responseBuffer. When moving from an error state back to a stream, the target may inherit most context but reset one field. When moving back to compose, some generation-only fields should disappear.
The transition API is designed to make those requirements visible:
Action.when({
sendMessage: ({ to, payload }) => {
return to.slice("Generate.Stream", {
newMessage: payload,
responseBuffer: "",
});
},
});The useful part is that to.slice is typed against the source and target slices. If the target requires a field that the current context cannot provide, the transition must supply it. If the target inherits compatible context, the transition does not have to restate it.
This turns a common bug class into editor feedback.
The question changes from "Did I remember to set the right fields before rendering the next screen?" to "Does this transition satisfy the target state's contract?"
Actions Are Exhaustive
If a slice declares actions, the reducer setup should account for them. The API uses Action.when(...) handlers plus an Action.exhaustive marker so the action surface can be checked against implementation.
That matters during product change.
Adding a new action to a model should not be a scavenger hunt. If the model says a state can dispatch retry, send, reset, or cycleTheme, the implementation should have to explain what each one does.
The same principle applies to rendering.
The match.useSlice helper gives view trees a typed alternative to loose switch statements:
return match.useSlice(
Chat,
when("Generate.Stream", () => <StreamView />),
when("Generate.Error", () => <ErrorView />),
match.else(() => <ComposeView />),
);The intent is not to make control flow clever. It is to keep slice names checked against the model and keep view branching close to the product states.
Fine-Grained Reactivity Without Giving Up Structure
That choice is practical. A state tree can become too expensive if every update forces broad React re-renders. The model gives structure, but the component still needs efficient reads.
Components receive observable context:
const { context$ } = useStore(Chat.slice("Generate.Error"));
const errorMessage = useWatch(context$.errorMessage);When a component needs several fields, usePick can unwrap a set of observable properties in one place:
const ctx = usePick(
context$,
"errorMessage",
"responseBuffer",
"retryCount",
);The goal is to combine two things that are often treated separately:
- a structured, explicit product-state model
- fine-grained reactive reads inside React components
I do not want a state model that is theoretically clean but painful to render. I also do not want reactive primitives that make product behavior implicit. The library experiments with putting both in the same system.
Layers as Product Boundaries
That is useful because product state rarely lives alone. A store might need initial context, service dependencies, or promises that are resolved when entering a state. A provider defines that boundary:
<ChatLayer context={{ list: [], draft: "" }}>
<ThreadView />
</ChatLayer>The tests include stacked layers for theme, auth/session, and todos. The point is not that every app needs a custom provider system. The point is that a state model becomes more useful when it can define the boundary where context, services, and child components meet.
In product code, those boundaries matter. They are where a flow gets initialized. They are where dependencies enter. They are where one part of the UI should not accidentally reach into another part's behavior.
What This Proves
The important idea is broader:
Complex UI behavior should be modeled as product behavior, not reconstructed from scattered implementation details.
When state is implicit, the cost shows up during change. A developer adds a new state, updates a component, touches a reducer, adjusts a hook, and then hopes the error path still works. The code may be typed, but the product flow is not necessarily typed.
This project asks what happens if the product flow is the type.
Slices define what data exists. Actions define what can happen. Transitions define what must change. Components bind to the state they are allowed to render. Runtime checks catch incorrect placement. Tests verify the contracts at both the React and type levels.
That is the kind of frontend architecture I want when a workflow has real complexity.
Not because types are an end in themselves.
Because product behavior deserves a place where it can be seen, changed, and checked.