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.


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 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.


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