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 UserProfile extends State {
  user = set(async () => {
    const res = await fetch('/api/user');
    return res.json();
  });
}

function Profile() {
  const { user } = UserProfile.use();
  return <h1>{user.name}</h1>; // guaranteed defined
}

// Wrap with Suspense:
<Suspense fallback={<Spinner />}>
  <Profile />
</Suspense>;

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

A Component gives you the boundary for free: set its fallback and any pending property in render() is covered, no <Suspense> wrapper needed. Details in Components. For a headless-State version of this pattern, see essentials/async.

Whichever boundary catches, the pending value must be owned above it. A boundary rebuilds the subtree it retries, so a State 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 State {
  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 state 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 State {
  data = set(() => fetchConfig());
}

A promise built in a field initializer would start before the state 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 State {
  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 State {
  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 state - 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 State {
  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 state 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 state 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 State {
  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 State {
  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 model owns what the view actually works with: filters, selection, derived values, actions.

The bridge is the class use() method. It runs on every render of the hosting component, so hooks are legal inside it - the query lives in the model, and components never see the query library at all:

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

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

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

    if (data) this.items = data;
    this.pending = isPending;
  }

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

function TodoList() {
  const { visible, pending, is: todos } = Todos.use();

  if (pending) return <Spinner />;

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

Assigning an unchanged value is a no-op - no event fires - so copying the query result in on every render costs nothing while the cache is stable. Writes flow the other way as ordinary assignments; a mutation hook slots into use() the same way.

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