Async workflows
Model loading, error and retry in state, keep bookkeeping in metadata, and ignore stale responses.
The problem this solves
A request has more than a result. The screen needs to know it is loading, that it failed, and what to do next. And when a user triggers two requests, the older answer must not overwrite the newer one.
Put every displayed state in the store
Keep status, the data and the error in the state. Components subscribe to what they show and re-render when
it changes. An async action returns a promise, so callers can also await it.
store.ts
import { createGlobalState } from 'react-global-state-hooks';
export interface User {
id: number;
name: string;
}
export interface UsersState {
status: 'idle' | 'loading' | 'success' | 'error';
users: User[];
error: string | null;
attempts: number;
}
export type FetchUsers = () => Promise<User[]>;
const initial: UsersState = { status: 'idle', users: [], error: null, attempts: 0 };
/** The fetcher is injected, so tests (and the demo) control what the "server" does. */
export function createUsersStore(fetchUsers: FetchUsers) {
return createGlobalState(initial, {
name: 'users',
// Bookkeeping that no component displays belongs in metadata: changing it never renders anything.
metadata: { latestRequest: 0 },
actions: {
restore() {
return ({ setState, setMetadata }) => {
setState(initial);
setMetadata({ latestRequest: 0 });
};
},
load() {
// Keep `tools` whole: `tools.metadata` is a live getter, but destructuring it copies the object as it
// is right now, which would be out of date after setMetadata.
return async (tools) => {
const { setState, setMetadata } = tools;
const requestId = tools.metadata.latestRequest + 1;
setMetadata({ latestRequest: requestId });
setState((state) => ({ ...state, status: 'loading', error: null, attempts: state.attempts + 1 }));
try {
const users = await fetchUsers();
// a newer request started while this one was in flight: ignore this late answer
if (tools.metadata.latestRequest !== requestId) return;
setState((state) => ({ ...state, status: 'success', users }));
} catch (error) {
if (tools.metadata.latestRequest !== requestId) return;
setState((state) => ({
...state,
status: 'error',
error: error instanceof Error ? error.message : 'Unknown error',
}));
}
};
},
},
});
}Steps
- Start. Set
status: 'loading'and clear the previous error. A retry is the same action. - Guard against stale answers. Give each request an id. Keep the latest id in metadata, since nothing displays it, and drop an answer whose id is no longer the latest.
- Finish. Set
successwith the data, orerrorwith a message. - Inject the fetcher. Passing the fetch function into a store factory lets tests control timing without a network.
See it run
The Async workflows example runs this store with a simulated failing server, and its tests cover success, error with retry, and the stale-answer case.