Expressive MVC
Guides

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 App and Header - 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 | undefined

Required 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 context

Useful 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 via State.new() and neither is "the" global instance - both are evicted from root lookup, rather than one silently winning. Explicit registration (a Provider, or Context.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, and def.

On this page