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


Next

On this page