Comparisons
The same component built with Zustand, Jotai, Redux, and MobX - side by side
How Expressive relates to the store libraries you already know. For the case against raw hooks, see Why Classes? - this page is about the libraries you'd otherwise pick instead.
The fairest comparison is a component-owned unit of state: reusable, self-contained, re-renders on change. Below, the consumer code is held identical everywhere it can be - only the cost of building the unit differs.
Local state
Say you want a reusable useFooBarBaz that a widget can own. Flip through:
import State from '@expressive/react';
class FooBarBaz extends State {
foo = 0;
bar = 'hello';
baz = true;
bump() {
this.foo++;
}
}
function Widget() {
const { foo, bar, baz, bump } = FooBarBaz.use();
return (
<button onClick={bump}>
{foo} · {bar} · {String(baz)}
</button>
);
}On the Expressive side, the destructure is the dependency list. Read a field, subscribe to it. Ignore it, don't. Nothing to declare, nothing to forget - no setters, no selectors, no store factory. Need a silent read? state.is.foo.
Shared state
Context is where store libraries quietly hand the problem back to you. In Expressive, your classes themselves are context keys - anything needing Theme just asks for it: another State via get(Theme), a component via Theme.get().
import State, { get } from '@expressive/react';
class Theme extends State {
mode = 'light';
toggle() {
this.mode = this.mode === 'light' ? 'dark' : 'light';
}
}
class Panel extends State {
theme = get(Theme);
get dark() {
return this.theme.mode === 'dark';
}
}
function ModeBadge() {
const { mode, toggle } = Theme.get();
return <button onClick={toggle}>{mode}</button>;
}No createContext<T>, no null default, no missing-provider guard, no Provider/Consumer pair to keep in sync. Every library lands back here eventually - Zustand has you wrap a store in React context yourself, Jotai's Provider scopes a whole atom store, MobX leaves it to you entirely.
vs Zustand
Zustand is a great lightweight store. The differences that matter in practice:
- Destructuring is the selector - no per-access selector functions or shallow-compare helpers.
- Classes are the organizational unit - no factory functions, no spread-based updates.
- One API for every scope - the same class can back a global store, a per-component instance, or a provider tree.
- Lifecycle is built in -
new()and automatic destruction replace manual subscribe/teardown.
vs Redux (and RTK)
Redux introduces actions, reducers, selectors, and a dispatch indirection. Expressive collapses all four into methods:
class Counter extends State {
count = 0;
increment() {
this.count++;
}
}- Updates: direct assignment instead of
dispatch(action). - Subscription: destructure instead of
useSelector. - Async: regular async methods instead of thunks or sagas.
- Composition: class inheritance instead of store merging.
Redux's time-travel devtools are real value. If you rely on them daily, factor that in - Expressive gives you plain objects and standard browser debugging instead.
vs MobX
MobX is the closest relative - both use observable objects with automatic dependency tracking. Expressive is what you get when that idea is allowed to own the whole problem:
- No
observer()wrapper. Miss one in MobX and you get stale renders. Expressive tracks throughuse()andget()themselves. - No
makeAutoObservable. ExtendingStateis enough - and methods arrive pre-bound. - Lifecycle, context, and Suspense are built in -
new(), class-keyed context, asyncset(). In MobX these are pieces you wire together yourself.
If you like MobX, you'll feel at home here. Expressive is simply more opinionated about the parts MobX leaves as an exercise.
When not to use Expressive
- Your team has a strict "functional only" code style.
- Your app is mostly server-rendered with minimal client state.
- You already have a state solution that works and causes no pain.
- You rely heavily on Redux devtools' time-travel debugging.
Expressive is a tool for a specific kind of complexity. If you don't have it, you don't need it.