Skip to content
react-global-state-hooks
Interactive example · Async

Every request state, covered

Walk through loading, failure and retry with an explicit state for each outcome, and an action that ignores late answers from older requests.

Try it hereLive example
Application preview

Loading the example…

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',
            }));
          }
        };
      },
    },
  });
}
Ready. Make a change to begin.Reset returns this example to its initial state.
Take it with you

Visible status belongs in state. Request bookkeeping belongs in metadata.

01

Turn on Simulate a failing server and load the users. The status moves through idle, loading and error; `attempts` counts every request.

02

Turn the server back on and press Retry. Retry is the same `load` action, and it clears the previous error.

03

The button is disabled while a request is in flight. The "server" answers after about 700 ms.

Read the full guide

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.

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',
            }));
          }
        };
      },
    },
  });
}

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:

fakeApi.ts
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:

AsyncDemo.tsx
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>
  );
}

Related documentation

Keep exploring

Another piece of the model.

↑↓ Navigate ↵ Open Esc CloseRGSH docs
Get started

Shared state. Precise subscriptions.
Built-in DevTools.