The code
The store takes the fetch function as an argument. The demo passes a fake one, and the tests pass their own, so the workflow is tested without timers or a network.
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',
}));
}
};
},
},
});
}The request counter lives in metadata, because no component displays it. When two requests overlap, the action compares its id with the latest one and drops the late answer.
The fake server, and the switch that makes it fail, which is a small store of its own:
import { createGlobalState } from 'react-global-state-hooks';
import { createUsersStore, type FetchUsers } from './store';
// The demo's "server" switch. It is a store too, so the checkbox and the fetcher share it.
export const useServer = createGlobalState({ failing: false }, { name: 'fakeServer' });
export const fetchUsers: FetchUsers = () =>
new Promise((resolve, reject) => {
setTimeout(() => {
if (useServer.getState().failing) {
reject(new Error('The server did not respond (simulated).'));
return;
}
resolve([
{ id: 1, name: 'Ada Lovelace' },
{ id: 2, name: 'Grace Hopper' },
{ id: 3, name: 'Margaret Hamilton' },
]);
}, 700);
});
export const useUsers = createUsersStore(fetchUsers);The component reads the status and calls the action:
import '../shared/demo.css';
import './async.css';
import { RenderCount } from '../shared/RenderCount';
import { useServer, useUsers } from './fakeApi';
const statuses = ['idle', 'loading', 'error', 'success'] as const;
const symbol = {
idle: 'M2 5h7l3 3h10v13H2z',
loading: 'M3 10a9 9 0 1 1 2 9M3 3v7h7',
error: 'm12 3 10 18H2zm0 6v5m0 3h.01',
success: 'm5 12 4 4L19 6',
};
function UsersPanel() {
const [{ status, users, error, attempts }, actions] = useUsers();
return (
<section className="demo-card async-stage-card" aria-label="Users">
<RenderCount />
<div className="async-track" aria-label="Request state">
{statuses.map((name) => (
<span className={name === status ? 'active' : undefined} key={name}>
{name}
</span>
))}
</div>
<div className={`async-stage async-stage--${status}`} aria-busy={status === 'loading'}>
<div className="async-symbol">
<svg className="icon" viewBox="0 0 24 24" aria-hidden="true">
<path d={symbol[status]} />
</svg>
</div>
<p className="async-status" role="status">
{status === 'idle' && 'Nothing loaded yet.'}
{status === 'loading' && 'Loading users…'}
{status === 'success' && `Loaded ${users.length} users.`}
{status === 'error' && `Failed: ${error}`}
</p>
<p className="async-hint">
{status === 'idle' && 'Load users to start the request.'}
{status === 'loading' && 'The request is in progress.'}
{status === 'success' && 'The same action reloads the list.'}
{status === 'error' && 'Nothing was lost. Turn off the failing server and try again.'}
</p>
{status === 'success' && (
<ul className="async-users">
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
<div className="async-actions">
<button
type="button"
className={status === 'success' ? 'demo-button' : 'demo-button demo-button--primary'}
onClick={() => actions.load()}
disabled={status === 'loading'}
>
{status === 'error' ? 'Retry' : status === 'success' ? 'Reload' : status === 'loading' ? 'Loading…' : 'Load users'}
</button>
<span className="async-attempts">attempts: {attempts}</span>
</div>
</div>
</section>
);
}
function ServerSwitch() {
const [failing, setServer] = useServer((server) => server.failing);
const [status] = useUsers((state) => state.status);
return (
<section className="demo-card" aria-label="Server">
<label className="async-switch">
<input
type="checkbox"
checked={failing}
disabled={status === 'loading'}
onChange={() => setServer((server) => ({ ...server, failing: !server.failing }))}
/>
<span>Simulate a failing server</span>
</label>
</section>
);
}
/** Returns both demo stores to idle. */
export const resetAsyncDemo = () => {
useUsers.actions.restore();
useServer.setState({ failing: false });
};
/** One-line trace for the workbench status bar. */
export function watchAsyncDemo(log: (line: string) => void) {
const stopUsers = useUsers.subscribe(
(state) => {
if (state.status === 'loading') log(`status: loading · attempt ${state.attempts}`);
else if (state.status === 'success') log(`status: success · users: ${state.users.length}`);
else if (state.status === 'error') log('status: error · retry available');
else log('status: idle');
},
{ skipFirst: true },
);
const stopServer = useServer.subscribe((server) => log(server.failing ? 'server: failing' : 'server: responding'), {
skipFirst: true,
});
return () => {
stopUsers();
stopServer();
};
}
export function AsyncDemo() {
return (
<div className="demo">
<div className="demo-grid demo-grid--stack">
<UsersPanel />
<ServerSwitch />
</div>
</div>
);
}