Context and Sharing
Component for, 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, Component } from '@expressive/mvc';
class Theme extends State {
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>
);
}Notice there's no plumbing between
AppandHeader- the class is the contract. A working version of this tree lives at composition/context.
Providing with <Component for>
The bare Component class - not a subclass - given a for attribute puts one State into context for its children.
Good to know:
<Component for>replacesProvider.ProviderandConsumerwere removed from@expressive/react; neither exists in@expressive/dom.
Providing a class
<Component for={Theme}>
<App />
</Component>The element constructs an instance, owns it, and destroys it on unmount. Its mount() runs with the element's. Other attributes are typed from the provided State and assigned to it on every render:
<Component for={Theme} color="red">
<App />
</Component>Changing for to another class releases the previous instance and provides a new one.
Providing an existing instance
Pass an instance only when something else owns it - for example, one a component created with State.use():
function Page() {
const theme = Theme.use();
return (
<Component for={theme}>
<App />
</Component>
);
}An instance passed in is not destroyed when the element unmounts, and its mount() is not called - its owner manages its lifetime.
Providing several states
One item per element. Several states belong to a parent State that owns them as fields:
class Root extends State {
theme = new Theme();
auth = new AuthService();
cart = new Cart();
}
<Component for={Root}>
<App />
</Component>;Each field registers into the same context, so descendants find Theme.get(), AuthService.get() and Cart.get() directly, and the three can get() each other as siblings. Nesting <Component for> elements works, but the inner State can see the outer, not the reverse.
Creation callback
<Component for={Theme} is={(theme) => theme.loadFromStorage()}>
<App />
</Component>is receives each instance the element constructs. It is only accepted with a class - an instance you pass in already exists.
Suspense and errors
<Component for={UserProfile} fallback={<Spinner />}>
<ProfileView />
</Component><Component for> adds no boundary of its own. Pass fallback to wrap children in a Suspense boundary, so any async set() in the provided state shows it. Pass catch to handle errors thrown by children - see error boundaries.
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-XXXXXX.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 once, on first render, without subscribing - the factory never runs again and the component doesn't re-render for this state:
AppState.get((app) => {
analytics.track('page', app.page);
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/mvc';
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 <Component for> (or a Component subclass), 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.
<Component for={TabGroup}>
<Component for={Tab} label="Home" />
<Component for={Tab} label="Profile" />
<Component for={Tab} label="Settings" />
</Component>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
}Reading context inline
Consumer was removed. Read context with State.get() in a small function component - property reads subscribe as usual:
const Themed = () => {
const theme = Theme.get();
return <p style={{ color: theme.color }}>Themed text</p>;
};Globals and the root context
Not everything wants a <Component for>. Some state is genuinely global - the session, the viewport, app settings. Declare those with static global and call State.new() at module scope:
// session.ts
import { State } from '@expressive/mvc';
class Session extends State {
static global = true;
user = 'gabe';
logout() {
this.user = '';
}
}
export default Session.new();State.new() activates the instance against Context.root, the global registry every context ultimately inherits from - and because Session declares static global, it registers there too. Any component, anywhere, can now call Session.get() - nothing to provide:
function Avatar() {
const { user } = Session.get(); // found via the root context
return <img src={`/avatars/${user}.png`} />;
}The declaration is the opt-in. Without it, a context-less State.new() instance is fully functional but private - it can read declared globals, but nothing can find it. And static global must be declared per class: a subclass of a global that doesn't redeclare it throws on activation, so an extended global can't leak into the shared root by accident.
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. A global's State.new() registers it at root, which claims it immediately and permanently; wrapping the instance in a context afterward won't move it. A private instance only falls back to root, so the first explicit context to claim it later still becomes its home.
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:
A global may not shadow another. A secondState.new()of the same global class throws, and so does a global subclass or supertype of one already in root - destroy the first (set(null)) before creating another. Two different subclasses of a shared global base don't throw; they collide only at the base, where both are evicted -Base.get()finds neither rather than one silently winning, while each subclass stays findable by its own type. Explicit registration (<Component for>, orContext.root.add(state, true)) bypasses both rules.
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();
}
<Component for={App}>
<View />
</Component>;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 element covers the whole tree, no stack required.
Scoped overrides
A nearer provider overrides lookups beneath it:
<Component for={Theme} color="light">
<Section />
<Component for={Theme} color="dark">
<Section /> {/* resolves the inner Theme */}
</Component>
</Component>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.