Migrating from Hooks
A pragmatic playbook for adopting Expressive State in an existing React codebase
Expressive is designed to coexist with hooks. You do not need to rewrite anything to start using it - every State 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 model, extract it into a State class. If the state is intrinsic to display logic - a shell, local router, tab panel, menu, editor surface, media player, form control, 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/reactExpressive has zero peer dependencies beyond React. It doesn't touch your existing state libraries - Redux, Zustand, React Query, Jotai, Recoil, and friends all continue to work.
import State, { Component, Provider, get, set, ref } from '@expressive/react';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
useStatecalls. - Has 1 or more
useEffectthat syncs state values or fetches data. - Has 1 or more
useCallbackwhose 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:
Componentfor state intrinsic to display logic. Usually that means definingrender().Componentwithoutrender()when React tree placement is the point. It passes children through while still providing context and Suspense/ErrorBoundary placement.Statefor headless models/controllers, even when they are contextual.- 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 State {
name = '';
email = '';
saving = false;
get dirty() {
return this.name !== initial.name || this.email !== initial.email;
}
}If UserForm is a display-agnostic State, replace the hooks in the component with UserForm.use():
const { is: form, name, email, saving, dirty } = UserForm.use();Writes change from setName(x) to form.name = x. If UserForm is a Component, read and write the same fields through this inside render() and methods. Derived values should usually move to getters, not to extra fields that need to be synchronized.
4b. Move effects
For a useEffect that performs setup/teardown, move it into the new() hook:
new() {
const id = setInterval(() => this.poll(), 5000);
return () => clearInterval(id);
}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 State {
width = window.innerWidth;
get compact() {
return this.width < 720;
}
protected new() {
const update = () => {
this.width = window.innerWidth;
};
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}
}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 and delete
Run the component, test the behavior, and delete the old hook calls. If something breaks, the migration is trivially reversible - the class lives in its own file.
Step 5 - Share without Provider stacks
Use Provider to introduce a headless model 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 State {
color = 'blue';
toggle() {
this.color = this.color === 'blue' ? 'red' : 'blue';
}
}
function App() {
return (
<Provider for={Theme}>
<Header />
<Main />
</Provider>
);
}
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 States of one provided are too. You do not need a Provider for every small controller:
class Control extends State {
theme = new Theme();
auth = new Auth();
}
<Provider for={Control}>
<App />
</Provider>;
function Header() {
const { color } = Theme.get();
// Theme resolves from AppState.theme
}Use Component instead when the owner 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 State {
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 Provider.
Step 7 - Bridge to existing hooks with use()
Some hooks cannot be replaced - useNavigate, useLocation, useTranslation, useQuery from a library. To use them inside a State class, define a use() method. It runs on every render of the consumer:
class Nav extends State {
shouldRedirect = false;
use() {
const navigate = useNavigate();
if (this.shouldRedirect) {
this.shouldRedirect = false;
navigate('/dashboard');
}
}
}
function SomeComponent() {
const { is: nav, shouldRedirect } = Nav.use();
// ... trigger nav.shouldRedirect = true somewhere
}This is the escape hatch - any hook you need can be bridged through use(). Use it sparingly; it runs every render. For one-shot setup, prefer new().
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 State 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
State tracks equality via ===. Pushing onto an array will not trigger an update:
this.items.push(item); // no update
this.items = [...this.items, item]; // worksIf you need to dispatch an update without replacing the value, use this.set('items') to manually signal the change.
Forgetting State.new() when constructing outside React
const counter = new Counter(); // constructs but does NOT activate
const counter = Counter.new(); // constructs AND activates - always use thisInside React, Counter.use() handles activation for you.
Assuming use() replaces useEffect
The class use() method runs on every render - it is a bridge for calling React hooks, not a lifecycle effect. For setup/teardown, use new(). For reactive side effects, use this.get(effect) inside new().
Expecting state to survive destructured variables
const { count } = Counter.use();
count = 5; // just rebinds the local - doesn't update statePrefer destructuring is first and aliasing it to the state concept, like const { is: counter, count } = Counter.use();. Use counter.count = 5 for writes after destructuring, or don't destructure the fields you write to.
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
- State Classes - defining fields, methods, and lifecycle.
- Reactivity - how tracking and computed values work.
- Components - the
Componentclass for self-rendering state.