Expressive MVC

Why Classes?

The organizational case for moving state into classes

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:

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.
  • dirty became a getter. Its inputs (name, email, initial) are tracked automatically - no dependency array to maintain, no way to forget one.
  • save became a regular async method. No useCallback, no stale closures.

And the component? Pure presentation. Place the class above it with <Component for> - that element owns the instance, assigns userId on every render, and shows fallback while initial loads - and the view just reads it:

const UserSettingsPage = ({ userId }: { userId: string }) => (
  <Component for={UserSettings} userId={userId} fallback={<p>Loading...</p>}>
    <UserSettingsForm />
  </Component>
);

function UserSettingsForm() {
  const { is: settings, name, email, dirty, saving, error, save } =
    UserSettings.get();

  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 form remains completely independent from the logic - it owns nothing, and creates nothing. Want a second view of the same feature - a modal, a sidebar, a mobile layout? Write a second function and place it under the same element, or under its own. The behavior stays put.

When the form is the feature, the class can render it instead: class UserSettings extends Component with a render(). A Component provides itself to everything it renders, so pieces of the form split out as functions calling UserSettings.get() - no wiring, no props. Either way, function components hold no state; they project it.


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. There is no dependency list to forget - tracking follows the reads themselves. (Reads still have to happen: a value read only inside a branch subscribes only when that branch runs.)

Testability without a renderer

Model 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. Views read the class; the element placing it owns it.

Portable by construction

Nothing in a Model imports React. The same class tree runs headless under App.new() - for tests, scripts and agents - renders under React, and renders under @expressive/dom with no virtual DOM at all. What does not port is hook code. That is why views here stay projections, and why Expressive is MVC you run in React rather than one more way to hold state in a hook.

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 Model 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 Model 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 runtime footprint - reactive proxies, lifecycle tracking, context management: ~11.7 kB gzip for a typical app, ~13.4 kB with every export used (measured and CI-gated). Tracked reads are proxy reads during render; reads in handlers or through is subscribe nothing. Measurable, not zero.
  • An adapter floor you cannot tree-shake - the React adapter works by patching things it does not own at import: Component.prototype, contextType, isReactComponent, the host runtime registry. Cross-package prototype patches cannot be shaken away, so importing @expressive/react at all costs ~10.8 kB before your first field. The headless core does shake properly (~6.8 kB for Model alone, ~10.7 kB for all of it).
  • Familiarity for new hires - engineers used to Redux or Zustand will need a brief ramp-up on classes and instructions.

Expressive coexists with hooks - useState is fine for a toggle in a leaf, and React's own hooks still bridge in at the edges. What it does not offer is a hook-shaped way to own state: ownership lives in classes, which is what lets the same code move between hosts.


Next

  • Comparisons - the same feature 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.

On this page