Getting Started
Install, write your first class, and learn how the pieces fit together
Install
npm install @expressive/mvc @expressive/react@expressive/mvc is the framework-agnostic core and a peer dependency of the adapter, so install both. Model, Component, and every instruction are named exports of the core:
import { Model, Component, get, set, ref } from '@expressive/mvc';@expressive/react supplies the host. Import it once, for its side effect, in your entry module:
// main.tsx
import '@expressive/react';It exports nothing for app code - everything comes from @expressive/mvc by name. Routing is a separate package, @expressive/router.
Working with an agent?
npx skills add gabeklein/expressive-mvcteaches it the whole API in one shot.
Requirements
React 16.14 or newer — interacts with react/jsx-runtime. React and @expressive/mvc are peer dependencies; nothing else is required.
On React 18+ the adapter will validate instance revisions before each commit, so a concurrent render that yields mid-work can never commit a tree mixing two revisions of one instance. React 16 and 17 do not yield mid-render, so nothing to validate — behavior is equivalent - no interleaving to protect against. See Concurrent rendering for the full contract.
Size
The whole install is about 13.5 kB gzip with everything imported, and closer to 11.8 kB for a typical app. Numbers are measured per import shape and gated in CI — see Bundle size.
React Native
The same package covers React Native and Expo — Metro resolves the adapter without extra configuration.
There is nothing renderer-specific to port — the adapter imports only react and react/jsx-runtime — and it has been exercised on an iOS simulator and an Android emulator in both Debug and Release builds: render and re-render on write through the native renderer, context, suspense, and error recovery. Every release additionally gates on a check that installs the published tarballs into a throwaway Expo app (SDK 57, React Native 0.86) and asserts Metro resolves them for iOS and Android, that the build compiles to Hermes bytecode, and that class fields keep their semantics under either of Metro's transform paths. Three things to know:
- Testing with
jest-exponeeds@expressiveadded totransformIgnorePatterns, because the build is ESM-only and Jest skipsnode_modulesby default. BrowserRouterfrom@expressive/routerreadswindow.location, which React Native does not define. UseRouterinstead — it keeps path and history in memory.LinkandNavLinksrender<a>and<ul>, which have no native host yet. Drive navigation fromRouterand render your ownPressable.
Hot reload (Vite)
Add the adapter's Vite plugin beside React's:
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import expressive from '@expressive/react/vite';
export default defineConfig({ plugins: [react(), expressive()] });Editing a Model or Component class then patches it in place - instances keep their values and re-render with the new methods, getters, render, and subcomponents, while React Refresh keeps your function components. Changing a field, the constructor, or new() reloads the page, as does editing a module that declares a class with #private members. The class must be a top-level class X (or let X = class) - a const binding cannot be patched. Dev server only; production builds are untouched.
Your first class
Ultimately, the workflow is pretty simple.
- Create a class. Fill it with the values, getters, and methods you'll need.
- Extend
Component, making it reactive and renderable. - Write a
render()that destructures what it reads fromthis. - Update those values on demand. The view will sync automagically. ✨
Start with a bare minimum:
import { Component } from '@expressive/mvc';
class Counter extends Component {
count = 0;
increment() {
this.count++;
}
decrement() {
this.count--;
}
render() {
const { count, increment, decrement } = this;
return (
<div>
<button onClick={decrement}>-</button>
<span>{count}</span>
<button onClick={increment}>+</button>
</div>
);
}
}
<Counter />;Three things are happening here:
<Counter />owns its instance - constructed when the element first renders, destroyed on unmount. One object for the component's whole life, never recreated per render.- Destructuring subscribes.
render()grabbedcount, so whencountchanges, this component (and only this component) re-renders. Values it ignores are ignored right back. - Methods come pre-bound.
incrementanddecrementcan be passed straight to event handlers -thisis always correct, nouseCallbackin sight.
Here's that same idea running for real - edit it if you like:
Fields are props
Every field doubles as an optional JSX prop, assigned on each render of the parent:
class Greeter extends Component {
name = 'World';
get greeting() {
return `Hello, ${this.name}!`;
}
render() {
const { greeting } = this;
return <h1>{greeting}</h1>;
}
}
<Greeter name="React" />;No props interface to keep in sync with state - the class is both. A prop the parent passes is the parent's to control - reassigned every render - so pass the ones it actually drives. To seed a value the user then edits, return it from is, which runs once:
<Greeter is={() => ({ name: user.firstName })} />A more complete example
A todo list with a computed value, local persistence, and a dash of validation - all in one class:
import { Component } from '@expressive/mvc';
interface Todo {
id: number;
text: string;
done: boolean;
}
class TodoList extends Component {
items: Todo[] = [];
draft = '';
nextId = 1;
get remaining() {
return this.items.filter((i) => !i.done).length;
}
protected new() {
this.get(() => {
const saved = localStorage.getItem('todos');
if (saved) {
this.items = JSON.parse(saved);
this.nextId = Math.max(0, ...this.items.map((i) => i.id)) + 1;
}
return this.set('items', () => {
localStorage.setItem('todos', JSON.stringify(this.items));
});
});
}
add() {
const text = this.draft.trim();
if (!text) return;
this.items = [...this.items, { id: this.nextId++, text, done: false }];
this.draft = '';
}
toggle(id: number) {
this.items = this.items.map((i) =>
i.id === id ? { ...i, done: !i.done } : i
);
}
render() {
const { items, draft, remaining, add, toggle } = this;
return (
<div>
<form
onSubmit={(e) => {
e.preventDefault();
add();
}}>
<input
value={draft}
onChange={(e) => (this.draft = e.target.value)}
placeholder="What needs doing?"
/>
<button type="submit">Add</button>
</form>
<p>{remaining} remaining</p>
<ul>
{items.map((item) => (
<li key={item.id} onClick={() => toggle(item.id)}>
{item.done ? <s>{item.text}</s> : item.text}
</li>
))}
</ul>
</div>
);
}
}Notice what
render()doesn't have: no effects, no memoization, no derived state, no setup. It's a pure projection of the fields above it - the effect innew()handles persistence once the component commits (client-only, solocalStorageis safe even under server render),remainingkeeps itself current, andset('items', ...)runs whenever the list changes.
The mental model
If you've used React hooks, you're used to state living inside function components. Expressive flips this: state lives in a class, and function components are thin projections of it.
When state belongs to one view, that class is the Component itself, as above. When several components share it, it's a plain Model - no render() - placed above them with <Component for>. Any function component below reads it with .get():
import { Component, Model } from '@expressive/mvc';
// The brain - data, derived values, and behavior
class SearchPage extends Model {
query = '';
results: string[] = [];
loading = false;
async search() {
this.loading = true;
const res = await fetch(`/api/search?q=${this.query}`);
this.results = await res.json();
this.loading = false;
}
}
// The faces - each renders the part of the brain it needs
function SearchBox() {
const { is: page, query, search } = SearchPage.get();
return (
<input
value={query}
onChange={(e) => (page.query = e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && search()}
/>
);
}
function SearchResults() {
const { results, loading } = SearchPage.get();
if (loading) return <p>Searching...</p>;
return (
<ul>
{results.map((r) => (
<li key={r}>{r}</li>
))}
</ul>
);
}
// The placement - creates the instance and provides it to everything below
const App = () => (
<Component for={SearchPage}>
<SearchBox />
<SearchResults />
</Component>
);SearchResults re-renders when results or loading change; typing in the box re-renders only SearchBox. Neither component creates or owns anything - they're functions of state that lives above them.
The is property
After destructuring, you lose write access - query = 'x' just rebinds a local variable. For this, every instance exposes is, a loop-back to itself. Prefer destructuring it first, aliased to the instance's concept:
const { is: page, query } = SearchPage.get();
page.query = 'hello'; // updates state and re-rendersInside a Component's render() you already have it - this.draft = ... writes directly.
is has a second trick: silent reads. Reading through is inside a tracking context does not subscribe - handy when you want a value without volunteering to re-render every time it changes. More on that in Reactivity.
Because state is a portable object, we can modify it from anywhere - and more crucially, whenever. That makes the async stuff pretty low maintenance: you write the business logic, the class handles dispatch. It also means:
- Testability -
SearchPage.new()outside of React, call methods, assert results. No renderer required. - Reusability - one
Modelclass can power multiple views (sidebar, modal, full page). - TypeScript - the class is the type. Every destructure is fully inferred.
- Navigability - a feature's data and behavior live in one file, not smeared across several hook calls.
When to create a class
You don't need a class for every component. Good signals you should reach for one:
- Three or more related pieces of state.
- An effect that syncs two or more state values.
- A handler that touches more than one state value.
- State shared between components.
- A custom hook that returns an object.
- Async logic - fetching, polling, websockets.
- Props being passed down mostly to support state sharing.
For single booleans, form field focus, or throwaway UI state, useState is still the right tool. Expressive is for the cases where state has behavior.
When it does, choose the class shape by ownership:
Componentwhen the state is intrinsic to one rendered unit - a shell, tab panel, form input, media player, editor surface.Modelwhen the state is display-agnostic, shared across components, or worth testing without rendering. Place it with<Component for>, or hold it as a field of the Component that owns it (page = new Pagination()).
Don't reach for Component just because a class is contextual - Model handles headless controls fine, and child instances of anything provided are available to descendants automatically.
And avoid splitting into FooState plus FooView by default. If the behavior only exists for Foo's own UI, class Foo extends Component is usually clearer.
Next steps
- Why Classes? - the organizational case for moving state out of components.
- Components - the
Componentclass, which lets state render itself. - Context - placing shared state with
<Component for>and reading it with.get(). - Model Classes - fields, methods, and lifecycle.
- Reactivity - how tracking, computed values, and batching work.