Comparisons
The same feature 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.
Every library makes a single counter short, so a lone widget proves little. The fair test is a feature that splits: one owner, several views, each re-rendering only for what it reads. Below, the views' markup is held identical - only the cost of the owner and the wiring differs.
One feature, two views
A player owns a track and a play state. Title shows the track; Toggle plays and pauses. Pausing should not re-render Title. Flip through:
Every other tab builds the same three things by hand: a per-instance store, a context to carry it, and a typed hook to read it back - then each view declares what it reads, through a selector or a wrapper. In Expressive the Component is all three. It owns the instance, provides itself to whatever it renders, and Player.get() is the typed hook. Destructuring is the selector: Title re-renders when track changes, not when playing does.
Growing the feature changes nothing about the owner - another view is another function calling Player.get(). And the fields are typed props: <Player track="Verse" />.
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 instance via get(Theme), a component via Theme.get().
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()/mount()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 Model {
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 throughrender()andget()themselves. - No
makeAutoObservable. ExtendingModelis enough - and methods arrive pre-bound. - Lifecycle, context, and Suspense are built in -
new()/mount(), 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.