Expressive MVC
Guides

Async and Suspense

Loading data, handling errors, and working with Suspense

Async data is a first-class citizen in Expressive. You don't need a query library, middleware, or thunks - async factories and Suspense integration are built into the set instruction.


Async factories

Pass an async function to set() and the property becomes a promise-backed value. Accessing it before resolution throws a Suspense-compatible promise; accessing it after resolution returns the value.

class Profile extends Component {
  user = set(async () => {
    const res = await fetch('/api/user');
    return res.json();
  });

  fallback = <Spinner />;

  render() {
    const { user } = this;

    return <h1>{user.name}</h1>; // guaranteed defined
  }
}

<Profile />;

A Component is its own Suspense boundary - fallback shows while any pending property read in render() loads, no <Suspense> wrapper needed. The instance lives outside the render that suspends, so it loads once and is still there when the render retries.

By default the factory is lazy - it runs the first time something reads the property. Pass true as the second argument to run it eagerly on activation:

data = set(async () => fetchData(), true); // runs immediately

Pass false to make it non-blocking - the property returns undefined while pending instead of suspending:

avatar = set(async () => fetchAvatar(), false); // User['avatar'] | undefined

For shared, headless data, the same shape is a Model placed with <Component for={UserProfile} fallback={<Spinner />}>, read below by function components with UserProfile.get() - see essentials/async. Details on boundaries in Components.

Whichever boundary catches, the pending value must be owned above it. A boundary rebuilds the subtree it retries, so an instance created below it is constructed again on every retry and requests again - a silent loop.


Required placeholders

A set<T>() with no argument is a required property - initially undefined, and reading it throws Suspense until something assigns a value:

class Session extends Model {
  userId = set<string>(); // suspends until set
  user = set(async () => {
    const res = await fetch(`/api/users/${this.userId}`);
    return res.json();
  });
}

The async user factory reads this.userId. If userId hasn't been assigned, the factory itself suspends - and the cascade resolves automatically when userId arrives. You describe a dependency graph ("user depends on userId") and the library handles the timing. ✨

Good to know:
If the instance is destroyed while something still awaits a pending property, that promise rejects (Session-K3Q9ZB is destroyed.) rather than hang forever. Anything awaiting a suspense throw should expect that.


Avoid direct promises

Pass a factory, not a raw Promise:

class Config extends Model {
  data = set(() => fetchConfig());
}

A promise built in a field initializer would start before the instance is activated - in React StrictMode or other abandoned constructions, that work continues with no live instance to receive it. So set() refuses outright: handing it a Promise throws a TypeError at init ("Direct promises are not supported... Use set(() => promise) instead."). With a factory, work doesn't start until the instance is live.


Async methods

For mutations, async methods work exactly as you'd expect:

class LoginForm extends Model {
  email = '';
  password = '';
  submitting = false;
  error = set<string | null>(null);

  async submit() {
    this.submitting = true;
    this.error = null;
    try {
      await api.login(this.email, this.password);
    } catch (e) {
      this.error = (e as Error).message;
    } finally {
      this.submitting = false;
    }
  }
}

No useCallback, no dependency arrays, no stale closures. The method reads live state via this every time it runs - async stuff is pretty low maintenance when dispatch is already taken care of.


Refreshing async data

Factory-backed properties are read-only - assigning to this.posts throws (Feed.posts is read-only.). To refresh, either make the property writable by pairing the factory with a callback:

class Feed extends Model {
  posts = set(
    async () => fetch('/api/posts').then((r) => r.json()),
    (posts) => console.log('posts arrived:', posts.length)
  );

  async refresh() {
    this.posts = await fetch('/api/posts').then((r) => r.json());
  }
}

Or push a value through the descriptor form of set, which bypasses the setter entirely:

async refresh() {
  const data = await fetch('/api/posts').then(r => r.json());
  this.set('posts', { value: data });
}

Either way, subscribers update as normal. If you want the factory itself to re-run from scratch, destroy and recreate the instance - or build the refresh semantic into a regular async method, like the Query base below.


Error handling

An async factory that throws propagates the error to the nearest React error boundary (or Component.catch()):

class UserProfile extends Model {
  data = set(async () => {
    const res = await fetch('/api/user');
    if (!res.ok) throw new Error('Failed to load user');
    return res.json();
  });
}

Inside a Component, you can handle this with catch():

class Profile extends Component {
  data = set(async () => loadUser());

  async catch(error: Error) {
    this.fallback = <p>Failed to load. Retrying...</p>;
    await new Promise((r) => setTimeout(r, 1000));
    // When catch resolves, render is retried.
  }

  render() {
    return <h1>{this.data.name}</h1>;
  }
}

The full catch() and fallback semantics live in Components.

A rejection nobody renders is different. An async new(), or a constructor callback returning a promise, has no reader for its failure to reach - the instance is still created, and the rejection goes to the class's catch handlers with kind setup. Unhandled, it escapes uncaught. The same goes for an async method that keeps writing after its instance is destroyed: those writes are stored, reported with kind dead, and unhandled, output nothing.


Reusable async patterns

A base class can encapsulate a loading pattern that subclasses specialize:

abstract class Query<T> extends Model {
  abstract load(): Promise<T>;

  data = set(() => this.load());

  async refresh() {
    this.set('data', { value: await this.load() });
  }
}

class UserQuery extends Query<User> {
  userId = set<string>();
  load() {
    return fetch(`/api/users/${this.userId}`).then((r) => r.json());
  }
}

The library ships no query abstraction because you don't need one - the shape that fits your app is a dozen lines of class code. A worked version of this exact pattern (request bookkeeping, reset) is at essentials/fetch.


Suspense in effects

Effects registered via state.get(effect) also participate in Suspense. If an effect reads a pending value, it pauses and retries when the value resolves:

state.get((current) => {
  const { user } = current; // suspends the effect if unresolved
  console.log('got user:', user);
});

While paused, updates to other tracked properties don't re-trigger it - the effect simply resumes once the value lands. Same mechanism React uses for components; you get it everywhere for free.


Debounced async via setter callbacks

The set callback form can manage async work that needs to cancel on re-triggering:

class Search extends Model {
  query = set('', (value) => {
    const timer = setTimeout(() => this.run(value), 300);
    return () => clearTimeout(timer); // runs before next update
  });
  results: string[] = [];

  async run(q: string) {
    const res = await fetch(`/api/search?q=${q}`);
    this.results = await res.json();
  }
}

The callback returns a cleanup function, called before the next update (or on destruction). That covers debouncing, abort controllers, and any other "cancel the previous thing when a new thing arrives" pattern.


With a query library

Expressive and a server-cache library are not rivals - they own different things. TanStack Query (or SWR) owns the server cache: request dedupe, retries, background refresh, invalidation. The control owns what the view actually works with: filters, selection, derived values, actions.

The bridge is a small function component: it calls the query hook and passes the result as props. Props are fields, so the control receives the data on every render without importing the query library or knowing it exists:

import { useQuery } from '@tanstack/react-query';

function TodoList() {
  const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos });

  if (!data) return <Spinner />;

  return <Todos items={data} />;
}

class Todos extends Component {
  items: Todo[] = [];
  filter: 'all' | 'open' = 'all';

  get visible() {
    if (this.filter === 'all') return this.items;
    return this.items.filter((t) => !t.done);
  }

  render() {
    const { visible } = this;

    return (
      <>
        <FilterBar onPick={(f) => (this.filter = f)} />
        {visible.map((t) => <TodoRow key={t.id} todo={t} />)}
      </>
    );
  }
}

Assigning an unchanged value is a no-op - no event fires - so re-passing the query result on every render costs nothing while the cache is stable. filter is never passed, so it stays the control's own. Writes flow the other way as ordinary assignments; a mutation hook can go at the top of render(), which runs as a real function component, so hooks are legal there.

For data without cross-view cache requirements, the async factory and async method patterns above cover fetching with no second library at all - reach for a query library when you need its cache semantics, not to fetch.


Next

On this page