Expressive MVC

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.
  • 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:

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. 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

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 runtime footprint - reactive proxies, lifecycle tracking, context management: ~8 kB min+gzip for a typical app, ~10 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 anything from @expressive/react costs ~7.9 kB before your first field. The headless core does shake properly (~4.6 kB for State alone, ~8 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 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.

On this page