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 immediatelyPass false to make it non-blocking - the property returns undefined while pending instead of suspending:
avatar = set(async () => fetchAvatar(), false); // User['avatar'] | undefinedA
Componentgives you the boundary for free: set itsfallbackand any pending property inrender()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
- Forms and Refs - validation callbacks and mutable handles.
- API: Instructions - every
setoverload in detail.