Expressive State
Guides

Context and Sharing

Provider, the get instruction, and upstream/downstream lookup

Expressive has a built-in, type-safe context system. 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>;
}

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 on creation. For multi-state providers, it runs once per instance created.

Suspense

<Provider for={UserProfile} fallback={<Spinner />}>
  <ProfileView />
</Provider>

The fallback prop wraps children in a Suspense boundary, so any async set() in the provided state triggers the fallback.


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.
  • Returns a new hook subscription 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, any accessed property that's currently undefined triggers Suspense. Use this when you want the rendering code to assume all data is present.

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 are resolved 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
  form = get(FormState, true, false);  // optional, single
}

Consumer (render prop)

For cases where you want to read 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().


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. Api looks up Auth from the shared context automatically.

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. This makes it the natural unit for a feature:

class Dashboard extends Component {
  filter = '';
  items = set(async () => loadItems());
  // Dashboard is now in context for any descendant.
}

function FilterBar() {
  const { filter, is } = Dashboard.get();
  return <input value={filter} onChange={e => is.filter = e.target.value} />;
}

<Dashboard>
  <FilterBar />
  <ItemList />
</Dashboard>

Next

  • Async - async factories, Suspense, and error recovery.
  • API: Hooks - State.use, State.get, Provider, Consumer.
  • API: Instructions - every overload of get, set, ref, and def.

On this page