Why Classes?
The organizational case for moving state out of components
React's hook model made a bold trade-off: instead of organizing code around data, organize it around when code runs. useState for mounts, useEffect for side effects, useMemo for derivations, useCallback for stable references. Every piece of state logic gets spread across these primitives, keyed by execution timing rather than by what it means.
That trade-off works beautifully for small components. It falls apart as features grow.
One feature, two shapes
Here's a moderately complex feature - a user settings form with fetch, dirty-checking, and save - written both ways. Flip between them:
class UserSettings extends State {
name = '';
email = '';
saving = false;
error?: string = undefined;
// suspends if accessed early - never undefined
userId = set<string>();
// async factory - runs on first access, suspends until ready.
// The setter callback keeps it writable, so save() can reset it.
initial = set(async () => {
const res = await fetch(`/api/users/${this.userId}`);
const data = await res.json();
this.name = data.name;
this.email = data.email;
return { name: data.name, email: data.email };
}, () => {});
// computed - tracks what it reads, always up to date
get dirty() {
return this.name !== this.initial.name || this.email !== this.initial.email;
}
async save() {
this.saving = true;
this.error = undefined;
try {
const body = new FormData();
body.append('name', this.name);
body.append('email', this.email);
await fetch(`/api/users/${this.userId}`, { method: 'PUT', body });
this.initial = { name: this.name, email: this.email };
} catch (e) {
this.error = (e as Error).message;
} finally {
this.saving = false;
}
}
}Count what's in the hooks version: 6 hook calls managing related state, 3 dependency arrays kept in sync by hand, and one closure-over-state bug waiting to happen - the fetch races if userId changes mid-flight, caught only because someone remembered a cancelled flag. Everything is trapped in the component: can't reuse it, can't test it without rendering, and if another view needs the same data, you're copy-pasting the whole mess.
Now look at what moved in the class:
- The hook calls became fields. Visible at the top of the class, together, in one place.
- The fetch effect became an async
set(). It suspends the component until loaded, and the work doesn't even start until the instance is live - abandoned constructions (hello, StrictMode) never fetch at all. dirtybecame a getter. Its inputs (name,email,initial) are tracked automatically - no dependency array to maintain, no way to forget one.savebecame a regular async method. NouseCallback, no stale closures.
And the component? Pure presentation:
function UserSettingsView(props: { userId: string }) {
const { is: settings, name, email, dirty, saving, error, save } =
UserSettings.use(props);
return (
<form
onSubmit={(e) => {
e.preventDefault();
save();
}}>
<input
value={name}
onChange={(e) => (settings.name = e.target.value)}
/>
<input
value={email}
onChange={(e) => (settings.email = e.target.value)}
/>
<button disabled={!dirty || saving}>Save</button>
{error && <p className="error">{error}</p>}
</form>
);
}Notice how the component remains completely independent from the logic. Want a second view of the same feature - a modal, a sidebar, a mobile layout? Write a second component. The behavior stays put.
What this buys you
Cohesion
Related state lives together. When a reviewer asks "how does saving work?", the answer is a method, not "read this whole file". The class reads top-to-bottom as the complete picture.
No dependency arrays
Every computed value and every effect tracks its own dependencies, based on what it actually reads. Forgetting a dependency is impossible - you'd have to read the value without accessing it. 🤔
Testability without a renderer
State classes are plain objects. Test them directly:
test('marks form dirty after edit', async () => {
const form = UserSettings.new({ userId: 'u1' });
// a pending async property throws its own promise - await it to settle
try { form.initial } catch (pending) { await pending }
form.name = 'New Name';
expect(form.dirty).toBe(true);
});No render utilities, no act(), no DOM. The tests run in milliseconds, and they test the logic at the same level the logic is written. More patterns in Testing.
Reuse across views
The same class can power a sidebar, a modal, a mobile layout, and a full page. Components consume the class; they don't own it.
Type safety as a side effect
The class is the type. Destructures are inferred, refactors propagate, and context lookups are keyed by the class itself - no createContext<T>() boilerplate. You don't write types for Expressive; you write classes, and TypeScript does the rest.
The real argument: navigability
The deepest reason to move state into classes isn't performance, or even test coverage. It's navigability - how quickly a human (or an AI) can load the relevant code into their head.
In a hook-heavy codebase, features are spread across hook calls, custom hooks, context providers, and component bodies. Understanding anything non-trivial means building a mental graph across multiple files and abstraction layers.
In a class-heavy codebase, a feature is one class. Open it, read it, understand it. The hierarchy is the class hierarchy. The data flow is method calls. Every tool you already have for reading code - outline views, go-to-definition, find-references - works exactly the way you expect.
This is also why classes are unusually friendly to agents. A State class is a self-contained unit with an explicit shape. An AI reading a hook-based component has to reconstruct the data model from execution order; an AI reading a State class just reads the class.
What you give up
To be honest about the trade-off:
this- you have to use it. If your team has a strict "functional only" rule, Expressive isn't for you.- A small runtime footprint - reactive proxies, lifecycle tracking, context management. It's not zero.
- Familiarity for new hires - engineers used to Redux or Zustand will need a brief ramp-up on classes and instructions.
Expressive does not try to replace hooks wholesale. It coexists. Use useState where it's fine; reach for a class where the complexity justifies it.
Next
- Comparisons - the same component built with Zustand, Jotai, Redux, and MobX, side by side.
- Migrating from Hooks - a step-by-step playbook for adopting Expressive in an existing codebase.