Context and Sharing
Provider, the get instruction, and upstream/downstream lookup
Expressive has a built-in, type-safe context system, and the class itself is the key. No createContext<T>(), no default values, no prop drilling - any State provided in an ancestor can be looked up anywhere below it.
import State, { Provider } from '@expressive/react';
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>
);
}Notice there's no plumbing between
AppandHeader- the class is the contract. A working version of this tree lives at composition/context.
Provider
Provider puts a State instance (or several) into context for its descendants.
Providing a class
<Provider for={Theme}>
<App />
</Provider>The provider creates an instance on mount and destroys it on unmount. Pass state fields as JSX attributes to set initial values:
<Provider for={Theme} color="red">
<App />
</Provider>Providing an existing instance
const theme = Theme.new();
<Provider for={theme}>
<App />
</Provider>;Instances passed in from outside are not destroyed when the provider unmounts - you manage their lifetime.
Providing multiple states
Pass a record to provide several at once:
<Provider for={{ theme: Theme, auth: AuthService, cart: Cart }}>
<App />
</Provider>Creation callback
<Provider for={Theme} is={(theme) => theme.loadFromStorage()}>
<App />
</Provider>The is callback runs once per instance created.
Suspense
<Provider for={UserProfile} fallback={<Spinner />} name="profile">
<ProfileView />
</Provider>The fallback prop wraps children in a Suspense boundary, so any async set() in the provided state triggers the fallback. The optional name prop labels that boundary in React DevTools - handy when you have a few of them.
Consuming from context
State.get()
Inside a component, State.get() looks up an instance from context and subscribes to accessed properties:
function ThemeToggle() {
const { color, toggle } = Theme.get();
return (
<button style={{ color }} onClick={toggle}>
Toggle
</button>
);
}- Throws if the state isn't provided above.
- Re-renders only when accessed properties change.
- Re-subscribes automatically if the provider is replaced upstream.
Optional lookup
const theme = Theme.get(false); // Theme | undefinedRequired values
const profile = UserProfile.get(true); // Required<UserProfile>With true, reading a property that is currently undefined throws an Error (UserProfile.name is required in this context.). That surfaces at the nearest error boundary - not Suspense - so use it where a missing value would be a bug, not a loading state.
Computed selector
Pass a factory to derive a value from context. The component only re-renders when the derived value changes (compared by ===):
function CartSummary() {
const summary = Cart.get((cart) => ({
total: cart.total,
count: cart.count,
empty: cart.items.length === 0
}));
if (summary.empty) return <p>Cart is empty</p>;
return (
<p>
{summary.count} items - ${summary.total}
</p>
);
}The factory receives a tracking proxy and a refresh function (see below).
Effect mode
Return null from the factory to run a side effect without subscribing to re-renders:
AppState.get((app) => {
console.log('user changed:', app.user);
return null;
});ForceRefresh
The second argument to a factory is a refresh function. Call it to force the component to re-render, pass it a promise to re-render after resolution, or pass it an async function to re-render before and after:
function DataView() {
const data = DataService.get((svc, refresh) => {
const reload = () => refresh(svc.fetch());
return { items: svc.items, reload };
});
return <button onClick={data.reload}>Reload</button>;
}The get instruction
Inside a State class, the get instruction declares a context dependency as a field:
import State, { get } from '@expressive/react';
class Panel extends State {
theme = get(Theme); // required - throws if not in context
maybe = get(OptionalSvc, false); // optional - T | undefined
}This is dependency injection: a class declares what it needs, and whoever provides it supplies it. When an instance is created inside a Provider tree (or a Component), get fields resolve automatically.
Upstream with callback
class Panel extends State {
theme = get(Theme, (theme, self) => {
console.log('found theme:', theme);
return () => console.log('detached');
});
}The callback runs once when the upstream is resolved. The optional returned function runs on destruction.
Downstream collection
class TabGroup extends State {
tabs = get(Tab, true); // readonly Tab[]
active = 0;
}
class Tab extends State {
label = '';
group = get(TabGroup);
}Pass true as the second argument to collect downstream instances of a type. The array updates as children are added or removed. Subclasses match; superclasses do not.
<Provider for={TabGroup}>
<Provider for={Tab} label="Home" />
<Provider for={Tab} label="Profile" />
<Provider for={Tab} label="Settings" />
</Provider>Downstream with callback
class Registry extends State {
items = get(Item, true, (item, self) => {
console.log('registered:', item);
return () => console.log('unregistered');
});
}Return false from the callback to prevent registration. Return a function for cleanup.
Downstream single
Sometimes you want a single child of a type, not an array:
class Container extends State {
form = get(FormState, true, true); // required single - throws if missing
draft = get(DraftState, true, false); // optional single - T | undefined
}Consumer (render prop)
For reading context inline without a new component:
import { Consumer } from '@expressive/react';
<Consumer for={Theme}>
{(theme) => <p style={{ color: theme.color }}>Themed text</p>}
</Consumer>;The child function receives a tracking proxy, so property reads subscribe just like State.get().
Globals and the root context
Not everything wants a Provider. Some state is genuinely global - the session, the viewport, app settings. For those, call State.new() at module scope:
// session.ts
import State from '@expressive/react';
class Session extends State {
user = 'gabe';
logout() {
this.user = '';
}
}
export default Session.new();State.new() registers the instance into Context.root, the global registry every context ultimately inherits from. Any component, anywhere, can now call Session.get() - no Provider required:
function Avatar() {
const { user } = Session.get(); // found via the root context
return <img src={`/avatars/${user}.png`} />;
}A full app built this way (viewport, session, and theme as globals) is at composition/globals.
Homes are permanent
Where a state activates decides where its own get() lookups originate - its home context - and that assignment is first-wins. State.new() homes to root, immediately and permanently; wrapping the instance in a context afterward won't move it.
The escape hatch is plain construction. new State() builds the instance without activating it, so the first explicit placement gets to claim it:
const cart = new Cart(); // constructed, not yet activated
new Context(cart); // cart's home is this contextUseful in tests, and in code that prepares a state before mounting it into a tree.
Good to know:
Root globals are honest about ambiguity. Create two implicit instances of the same type viaState.new()and neither is "the" global instance - both are evicted from root lookup, rather than one silently winning. Explicit registration (a Provider, orContext.root.add(state, true)) bypasses this.
That's the short version - constructor forms, ctx.add, eviction and subtype rules are all in API: Utilities.
Composition patterns
Services tree
Classes can compose by owning each other:
class Auth extends State {
/* ... */
}
class Api extends State {
auth = get(Auth);
// ...
}
class App extends State {
auth = new Auth();
api = new Api();
}
<Provider for={App}>
<View />
</Provider>;When App is constructed, auth and api activate together and register into App's context. Descendants can call Auth.get() or Api.get() directly - one Provider covers the whole tree, no stack required.
Scoped overrides
Nested providers override inner lookups:
<Provider for={Theme} color="light">
<Section />
<Provider for={Theme} color="dark">
<Section /> {/* resolves the inner Theme */}
</Provider>
</Provider>Feature boundaries
A Component subclass is both a React component and a context container:
class Dashboard extends Component {
filter = '';
items = set(async () => loadItems());
// Dashboard is now in context for any descendant.
}
function FilterBar() {
const { is: dashboard, filter } = Dashboard.get();
return (
<input
value={filter}
onChange={(e) => (dashboard.filter = e.target.value)}
/>
);
}
<Dashboard>
<FilterBar />
<ItemList />
</Dashboard>;Use State for headless models like auth, theme, or an API service. Use Component when the class should exist in the tree: layouts, route shells, dashboards, wizards, tab groups, toast hosts. The container is the context. ✨
Next
- Async - async factories, Suspense, and error recovery.
- API: Utilities - the full Context reference: constructors,
add, root eviction. - API: Instructions - every overload of
get,set,ref, anddef.