Expressive MVC

Migrating from Hooks

A pragmatic playbook for adopting Expressive MVC in an existing React codebase

Expressive is designed to coexist with hooks. You do not need to rewrite anything to start using it - every Model class is fully optional, and every component you don't touch continues to work. This guide walks through the migration path that teams typically take when adopting Expressive as a state backbone.


The guiding principle

Don't migrate hooks. Migrate ownership.

A "feature" is a unit of behavior - a form, a wizard, a search page, a shopping cart, a chat thread. When you identify one, ask whether that behavior is a Model or a component.

If the state is headless, testable without rendering, or useful as a domain model, extract it into a Model class. If the state is intrinsic to display logic - a shell, local router, tab panel, menu, editor surface, media player, form input, or toast host - put it directly on a Component.

This is deliberately bottom-up. You don't need buy-in from the whole team before you start. You don't need a migration epic. You don't need to touch routing, build config, or tests. You just rewrite the next feature you're already working on.


Step 1 - Install and import

npm install @expressive/mvc @expressive/react

@expressive/mvc is the core and a peer dependency of the adapter; beyond it, Expressive needs only React. It doesn't touch your existing state libraries - Redux, Zustand, React Query, Jotai, Recoil, and friends all continue to work.

Import the adapter once in your entry module, then import everything else from the core by name:

// main.tsx
import '@expressive/react';
import { Model, Component, get, set, ref } from '@expressive/mvc';

Step 2 - Find a good first target

The best first migration target is a component that scores high on this checklist:

  • Uses 3 or more related useState calls.
  • Has 1 or more useEffect that syncs state values or fetches data.
  • Has 1 or more useCallback whose dependency array is non-trivial.
  • Contains business logic in the render body (validation, transformation, coordination).
  • Would benefit from testing without rendering.

You're looking for friction - a component you've already been annoyed by. Forms, wizards, and data-heavy dashboards are classic sweet spots. Skip components that use one or two useState calls for simple UI state; they'd be more code to migrate than they're worth.


Step 3 - Choose the class shape

Before moving code, choose the destination:

  • Component for state intrinsic to display logic. Usually that means defining render().
  • Component without render() when React tree placement is the point. It passes children through while still providing context and Suspense/ErrorBoundary placement.
  • Model for headless state and logic, even when it is contextual - placed above its readers with <Component for>, or held as a field of the Component that owns it.
  • Hooks for trivial local UI state.

Don't create UserFormState plus UserFormView just because hooks were present. If the form's state, handlers, validation, lifecycle, and JSX are a single reusable control, class UserForm extends Component may be the cleanest migration.


Step 4 - Extract state, one pass at a time

Work in small, verifiable steps. The migration is reversible at every step.

4a. Separate source state from derived state

Start by identifying which hook values are directly written by events, async callbacks, or subscriptions. Those become class fields:

// Before
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [saving, setSaving] = useState(false);
const dirty = name !== initial.name || email !== initial.email;
class UserForm extends Model {
  name = '';
  email = '';
  saving = false;

  get dirty() {
    return this.name !== initial.name || this.email !== initial.email;
  }
}

Derived values move to getters, not to extra fields that need to be synchronized.

Write the class beside the existing component. Nothing renders it yet, so the app keeps working while you fill it in over the next steps; Step 4e moves the JSX in and retires the hooks.

4b. Move effects

For a useEffect that opens a client resource - a timer, a listener, a socket - move it into mount(). It runs once when the owning component commits, never during server render, and its returned function runs on unmount:

mount() {
  const id = setInterval(() => this.poll(), 5000);
  return () => clearInterval(id);
}

Setup that accompanies the instance itself - a listener on its own state, an initial value computed from applied props - goes in new(), which runs once after activation wherever the instance is created, server included:

new() {
  return this.set('query', () => (this.page = 0));
}

For a useEffect that syncs two values, delete it - replace the target with a computed property:

// Before
const [dirty, setDirty] = useState(false);
useEffect(() => {
  setDirty(name !== initial.name || email !== initial.email);
}, [name, email]);
// After
get dirty() {
  return this.name !== initial.name || this.email !== initial.email;
}

For a useEffect that fetches, consider an async set():

user = set(async () => {
  const res = await fetch(`/api/users/${this.userId}`);
  return res.json();
});

For a useEffect that subscribes to an external source, store the external source's raw value and derive the resolved view from it:

class Viewport extends Model {
  width = Infinity; // desktop-first until measured

  get compact() {
    return this.width < 720;
  }

  mount() {
    const update = () => {
      this.width = window.innerWidth;
    };

    update();
    window.addEventListener('resize', update);
    return () => window.removeEventListener('resize', update);
  }
}

mount() runs once when the owning component commits - and never during server render, which is why window is safe inside it where a field initializer or new() would crash on the server. See lifecycle hooks.

4c. Move handlers to methods

// Before
const save = useCallback(async () => {
  setSaving(true);
  await api.save({ name, email });
  setSaving(false);
}, [name, email]);
// After
async save() {
  this.saving = true;
  await api.save({ name: this.name, email: this.email });
  this.saving = false;
}

Methods are automatically bound - you can destructure save and pass it as a handler directly, no useCallback needed.

4d. Verify without rendering

The class runs without React, so check its behavior before wiring it in:

const form = UserForm.new();

form.name = 'Ada';
expect(form.dirty).toBe(true);

If something is off, nothing has changed yet - the class lives in its own file, and the component still runs on its hooks.

4e. Move the view into the class

With the class complete, make it the component: extend Component, turn props into fields, move the body into render(), and delete the hook calls:

// Before
function UserFormView({ onSaved }: { onSaved: () => void }) {
  // useState, dirty and useCallback(save) from 4a-4c

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        save().then(onSaved);
      }}>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
      <button disabled={!dirty || saving}>Save</button>
    </form>
  );
}
// After
class UserForm extends Component {
  name = '';
  email = '';
  saving = false;

  // ...dirty and save() as before

  render(props = {} as { onSaved: () => void }) {
    const { name, email, saving, dirty, save } = this;

    return (
      <form
        onSubmit={(e) => {
          e.preventDefault();
          save().then(props.onSaved);
        }}>
        <input value={name} onChange={(e) => (this.name = e.target.value)} />
        <input value={email} onChange={(e) => (this.email = e.target.value)} />
        <button disabled={!dirty || saving}>Save</button>
      </form>
    );
  }
}

<UserForm onSaved={close} />;

What changed is ownership, made explicit. The instance is the element - created when <UserForm /> first renders, destroyed on unmount - instead of state cells scattered through a function's body. Every field is now an optional, typed prop (<UserForm name={user.name} />), extra props are declared on render(), and this is the write handle. A suspending field like an async set() gets a home too: the Component is its own Suspense boundary, showing its fallback while the instance - which lives outside the render - loads.

The Component form is not shorter than the hook form. What it buys is that the class now provides itself, so when the view grows it splits into functions calling UserForm.get() - no context, no props to rewire.

If the state turns out to be shared - a second view needs it - keep it a Model, place it with <Component for>, and let each view read it with .get(). That's Step 5.


Step 5 - Share without Provider stacks

Use <Component for> to introduce a headless instance or an app/root state at a boundary:

// Before
const ThemeContext = createContext<{ color: string; toggle: () => void }>(
  null!
);

function App() {
  const [color, setColor] = useState('blue');
  const toggle = useCallback(
    () => setColor((c) => (c === 'blue' ? 'red' : 'blue')),
    []
  );
  return (
    <ThemeContext.Provider value={{ color, toggle }}>
      <Header />
      <Main />
    </ThemeContext.Provider>
  );
}
// After
class Theme extends Model {
  color = 'blue';
  toggle() {
    this.color = this.color === 'blue' ? 'red' : 'blue';
  }
}

function App() {
  return (
    <Component for={Theme}>
      <Header />
      <Main />
    </Component>
  );
}

function Header() {
  const { color, toggle } = Theme.get();
  return (
    <button style={{ color }} onClick={toggle}>
      {color}
    </button>
  );
}

The class is the context key. No manual createContext, no default values, no Provider.Consumer render props.

Child instances of one provided are too. You do not need a <Component for> per control - one element provides one instance, so gather several as fields of a parent:

class Control extends Model {
  theme = new Theme();
  auth = new Auth();
}

<Component for={Control}>
  <App />
</Component>;

function Header() {
  const { color } = Theme.get();
  // resolves from Control.theme
}

App-wide state needs one element at the top - a render-less Global Component owning Theme and the rest; anything below finds each by Theme.get(). See it in the Playground.

Use a Component subclass instead when the owner itself needs to exist in the React tree. A Boundary may have little UI, but its Suspense/ErrorBoundary placement is the point. A Toasts root also belongs in the tree, but it should justify render() by injecting toast subcomponents beside the wrapped app.


Step 6 - Collapse custom hooks into classes

Custom hooks that return objects are the easiest migration wins. A hook like this:

function useCart() {
  const [items, setItems] = useState<Item[]>([]);
  const total = useMemo(
    () => items.reduce((s, i) => s + i.price * i.qty, 0),
    [items]
  );
  const add = useCallback((item: Item) => setItems((xs) => [...xs, item]), []);
  const remove = useCallback(
    (id: string) => setItems((xs) => xs.filter((x) => x.id !== id)),
    []
  );
  return { items, total, add, remove };
}

Becomes:

class Cart extends Model {
  items: Item[] = [];

  get total() {
    return this.items.reduce((s, i) => s + i.price * i.qty, 0);
  }

  add(item: Item) {
    this.items = [...this.items, item];
  }

  remove(id: string) {
    this.items = this.items.filter((x) => x.id !== id);
  }
}

The class version is shorter, testable without rendering, and shareable across components via <Component for>.


Step 7 - Bridge to existing React hooks

Some hooks cannot be replaced - useNavigate, useLocation, useTranslation, useQuery from a library. Bridge them from outside: read the hook in a small function component and pass what it returns as props. Props are fields, so the class receives the value on every render and never knows where it came from:

class SearchPage extends Component {
  query = '';

  get results() {
    return search(this.query);
  }

  render() {
    const { query, results } = this;
    // ...
  }
}

function SearchRoute() {
  const [params] = useSearchParams();

  return <SearchPage query={params.get('q') ?? ''} />;
}

SearchPage stays testable as SearchPage.new({ query: 'shoes' }), with no router in sight.

The escape hatch, for a hook whose result the view itself calls - navigate, t - is calling it inside render(). A Component's render() runs as a real function component, so this works, but it reintroduces hook-order rules inside the class: keep such a call at the top of render(), and never let it spread to methods or getters.

class Checkout extends Component {
  saving = false;

  async place() {
    this.saving = true;
    await api.placeOrder();
    this.saving = false;
  }

  render() {
    const navigate = useNavigate();
    const { saving, place } = this;

    return (
      <button
        disabled={saving}
        onClick={() => place().then(() => navigate('/dashboard'))}>
        Place order
      </button>
    );
  }
}

Keep hook calls out of methods and getters - they run outside render. For one-shot setup, use mount() for client resources and new() for setup that accompanies the instance itself.


Step 8 - Leave the rest alone

You don't need to migrate every component. Leaf components with a single useState, pure presentational components, components that consume a store library you're not ready to replace - leave them all alone. The goal is to reduce friction, not to hit 100% coverage.

Teams that adopt Expressive successfully usually end up in a hybrid state: a handful of Model classes at the feature boundaries, smart Components where behavior is intrinsic to a view, and many unchanged presentational components beneath them.


Common pitfalls

Mutating arrays and objects

Model tracks equality via ===. Pushing onto an array will not trigger an update:

this.items.push(item); // no update
this.items = [...this.items, item]; // works

Copy-on-write is the habit worth keeping from hooks. But when a collection is mutated a lot - a game board, a live buffer, rows edited in place - declare it with has() and mutate away:

import { has } from '@expressive/mvc';

class Game extends Model {
  board = has<string>(Array(9).fill(''));

  play(index: number, mark: string) {
    this.board.set(index, mark); // updates, in place
  }
}

A has() list tracks per index, so a component reading board.get(4) only re-renders when that cell changes; map() does the same for entries keyed by name. See Arrays & Collections for the full contract, or poke the tic-tac-toe example to watch it work.

If you just need to signal "this changed" without replacing the value, this.set('items') dispatches an update manually.

Forgetting Model.new() when constructing outside React

const counter = new Counter(); // constructs but does NOT activate
const counter = Counter.new(); // constructs AND activates - always use this

The first form warns if nothing activates it before the end of the tick, so this mistake announces itself rather than leaving you with an inert instance.

Inside React, <Counter /> and <Component for={Counter}> handle activation for you.

Treating render() as a lifecycle

render() runs on every render - a place to read state and call bridging hooks, not to start work. Client resources - timers, listeners, anything touching window - go in mount(); setup that accompanies the instance itself goes in new(). For reactive side effects, use this.get(effect) inside new().

Expecting state to survive destructured variables

const { count } = Counter.get();
count = 5; // just rebinds the local - doesn't update state

Writes pass straight through the destructured values, so no alias is needed for most code. Reach for is in the one case it earns: retaining the root object alongside sibling values from the same snapshot, like const { is: counter, count } = Counter.get();.


When to stop

A good rule of thumb: if a component has 0-2 useState calls, no effects, and no computed values, leave it. Expressive has value where state has behavior. Below that threshold, classes are just more code.

The goal isn't uniformity - it's clarity. Migrate the messes. Keep the clean stuff clean.


Next

  • Model Classes - defining fields, methods, and lifecycle.
  • Reactivity - how tracking and computed values work.
  • Components - the Component class for self-rendering state.

On this page