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 instance provided in an ancestor can be looked up anywhere below it.
import { Model, Component } from '@expressive/mvc';
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>
);
}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 instance 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 instance 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, documents created with Doc.new() and shown by whichever pane is pointed at them:
const docs = [Doc.new(), Doc.new()];
function Pane({ index }: { index: number }) {
return (
<Component for={docs[index]}>
<Toolbar />
<Canvas />
</Component>
);
}Toolbar and Canvas call Doc.get() and see the pane's document; changing index swaps what they read.
An instance passed in is not destroyed when the element unmounts, and its mount() is not called - its owner manages its lifetime.
Providing several instances
One item per element. Several instances belong to a parent instance that owns them as fields:
class Root extends Model {
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 instance 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 instance shows it. Pass catch to handle errors thrown by children - see error boundaries.
Consuming from context
Model.get()
Inside a component, Model.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 instance 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 | undefinedValues that may be unset
Hold the value as a Model field - a field instance joins its owner's context. Destructure it and render conditionally; the child reads it from context, so it only ever renders where the value exists:
class Settings extends Model {
draft?: Draft = undefined;
}
function SettingsPanel() {
const { draft } = Settings.get();
if (draft) return <Editor />;
return <Spinner />;
}
function Editor() {
const { name } = Draft.get();
return <input value={name} />;
}If the value has a single view of its own, make it a Component and place it directly: if (draft) return <>{draft}</>.
Deriving values
Destructure what the component renders and derive inline, or declare a getter on the Model so the derivation is memoized and shared:
function CartSummary() {
const { items, total } = Cart.get();
if (items.length) return <p>{items.length} items - ${total}</p>;
return <p>Cart is empty</p>;
}Side effects belong in the Model - mount(), or an instance effect with state.get(effect).
Custom lookup
A class may override static get with its own arguments - User.get(id) - and keep context and render tracking. See the Model.get reference for how an override delegates to super.get and calls Context.get.
The get instruction
Inside a Model class, the get instruction declares a context dependency as a field:
import { Model, get } from '@expressive/mvc';
class Panel extends Model {
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 Model {
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 Model {
tabs = get(Tab, true); // readonly Tab[]
active = 0;
}
class Tab extends Model {
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 Model {
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 Model {
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 Model.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>;
};App-wide state and the root context
Some state belongs to the whole app - the session, the viewport, settings. Give it to a component at the top - no render() needed, it renders its children:
// app.tsx
import { Component } from '@expressive/mvc';
class Global extends Component {
session = new Session();
theme = new Theme();
}
export default () => (
<Global>
<Layout />
</Global>
);A component provides itself and what it owns to everything below, so any component there can call Session.get():
function Avatar() {
const { user } = Session.get(); // found through Global
return <img src={`/avatars/${user}.png`} />;
}That's the point of Global - it makes each piece of shared state available by its own type. Components ask for Session, not for Global and then its session; Global.get() works, but nothing below should need to know where Session lives.
Every context descends from Context.root. A Model.new() outside any context resolves its own lookups against root, but never registers there - nothing can find it until something provides it. To make an instance findable without a provider, pass root when creating it - it's removed again when the instance is destroyed:
Session.new(Context.root);A full app built the provided way (viewport, session, and theme) is at composition/globals.
Placing instances
Where an instance activates decides where its own get() lookups originate - its home context - and that assignment is first-wins. A Model.new() only falls back to root, so the first explicit context to claim it later still becomes its home, though its initialization already ran without it.
To choose the home up front, pass the context to .new() - the instance is placed before its initialization runs:
const cart = Cart.new(context); // cart's home is contextOr construct without activating - new Model() builds the instance, so the first explicit placement gets to claim it before anything runs:
const cart = new Cart(); // constructed, not yet activated
new Context(cart); // cart's home is this contextUseful in tests, and in code that prepares an instance before mounting it into a tree.
When more than one context is in play, the first claim wins:
- Claimed before activation - a host mounting the instance,
ctx.add/ctx.pushbefore it activates, or an owner adopting it as a field child (an owned child always lives where its owner does). That claim decides the home, and context arguments are ignored. This is how a component rendered by React keeps its own layer even though React hands its constructor the parent context. - Context arguments - the first one becomes the home, and later ones are ignored. So a default goes last, like a fallback (the opposite of initial values, where later wins), and a subclass that must decide its placement passes its context first:
super(context, ...args), orsuper(props, context, ...rest)for a component.
When a context "didn't take", something earlier had the claim - the host, the owner, or an earlier argument.
Cart.new(...args, Context.root); // root unless args brought a contextAn owned child - session = new Session() - always lives where its owner does. Writing Session.new(context) in a field instead makes a guest - homed there, but not owned, so it is never destroyed with the parent. Created in a field initializer, nothing ever releases it; make guests where their lifetime is held and pass them in.
A component given a context this way - Panel.new(props, context) - is added to it like any instance, visible to its siblings there - two of one class in the same context make a lookup throw. To mount one as a host would, under its own layer, push it with a cleanup: context.push(panel, () => panel.set(null)).
An entry lasts until the instance is destroyed. Popping the context removes the entry but leaves the instance alive, and Context.get(instance) still returns the popped context - destroy it with set(null) to release it.
That's the short version - constructor forms and ctx.add are in API: Utilities.
Composition patterns
Services tree
Classes can compose by owning each other:
class Auth extends Model {
/* ... */
}
class Api extends Model {
auth = get(Auth);
// ...
}
class App extends Model {
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 Model for headless domain 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.