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'] | undefinedRequired placeholders
A set<T>() with no argument is a required property - it's 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 is set.
This pattern is powerful: you describe a dependency graph ("user depends on userId"), and the library handles the timing.
Direct promises
You can also pass a raw promise. Reads suspend until it resolves:
class Config extends State {
data = set(fetchConfig()); // promise constructed eagerly
}The promise is constructed when the field is initialized, so it starts fetching immediately.
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.
Refreshing async data
Because async set() is just a property, refreshing it means reassigning:
class Feed extends State {
posts = set(async () => fetch('/api/posts').then(r => r.json()));
async refresh() {
this.posts = await fetch('/api/posts').then(r => r.json());
}
}Or, using the set('posts', { value: ... }) descriptor form to bypass the factory entirely:
async refresh() {
const data = await fetch('/api/posts').then(r => r.json());
this.set('posts', { value: data });
}If you want the factory to re-run from scratch, destroy and recreate the state - or build the refresh semantic into a regular async method.
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>;
}
}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. You can build the shape that fits your app in a dozen lines of class code.
Suspense in effects
Effects registered via state.get(effect) also participate in Suspense. If an effect accesses 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);
});This is the 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, which is called before the next update (or on destruction). This gives you debouncing, abort controllers, or 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.