Skip to the code
cf.

useFetch

Write useFetch(url) and export it from useFetch.js. It loads JSON for a component, like the tea shop's menu from /api/teas, and returns { data, error, loading, refetch }.

When there's a url, it calls fetch(url, { signal }) with the signal of an AbortController. While the request is out, loading is true, and it's already true on the first render, so a component never sees a moment with no data, no error, and no loading. When the request succeeds, data is the parsed JSON body and loading is false.

A response that isn't ok (response.ok is false) is a failure: error becomes new Error("Request failed with status 500"), with the real status. A request that fails outright is a failure too, and error is whatever fetch rejected with. Either way, loading goes back to false. Until something fails, error is null.

When url changes, start again: data and error go back to null, the old request is aborted, and its answer is ignored if it still arrives. Cancelling isn't a failure, so never show the AbortError an aborted request rejects with. When url is null or empty, fetch nothing: loading is false, and data and error are null.

refetch() asks for the same url again. While it waits, loading is true, error is null, and data keeps the last answer, so the page doesn't flash empty. Removing the component aborts any request still out.

function TeaMenu() {
  const { data, error, loading, refetch } = useFetch("/api/teas");
  if (error) return <button onClick={refetch}>Try again</button>;
  if (loading && !data) return <p>Loading…</p>;
  return (
    <ul>
      {data.map((tea) => (
        <li key={tea.id}>{tea.name}</li>
      ))}
    </ul>
  );
}

The Preview shows Demo.jsx, a tea menu that uses your hook. It pretends to be the server, so oolong fails on the first try.

What the tests check

  1. fetches the url with a signal, and aborts it when the component goes away
    const answer = deferred();
    const fetch = mockFetch(() => answer.promise);
    const { unmount } = renderHook(() => useFetch("/api/teas"));
    expect(fetch.calls.map((call) => call.url)).toEqual(["/api/teas"]);
    const signal = fetch.calls[0].init.signal;
    expect(signal).toBeTruthy();
    expect(signal.aborted).toBe(false);
    unmount();
    expect(signal.aborted).toBe(true);
    
  2. is loading until the data arrives
    const answer = deferred();
    mockFetch(() => answer.promise);
    const { result } = renderHook(() => useFetch("/api/teas"));
    expect(result.current.loading).toBe(true);
    expect(result.current.data).toBeNull();
    expect(result.current.error).toBeNull();
    answer.resolve([{ id: 1, name: "Sencha" }]);
    await waitFor(() => expect(result.current.loading).toBe(false));
    expect(result.current.data).toEqual([{ id: 1, name: "Sencha" }]);
    expect(result.current.error).toBeNull();
    
  3. a response that isn't ok becomes an error
    mockFetch(() => fail(500));
    const { result } = renderHook(() => useFetch("/api/teas"));
    await waitFor(() => expect(result.current.loading).toBe(false));
    expect(result.current.error).toBeInstanceOf(Error);
    expect(result.current.error.message).toBe("Request failed with status 500");
    expect(result.current.data).toBeNull();
    
  4. a request that fails outright becomes an error
    mockFetch(() => {
      throw new TypeError("Failed to fetch");
    });
    const { result } = renderHook(() => useFetch("/api/teas"));
    await waitFor(() => expect(result.current.loading).toBe(false));
    expect(result.current.error).toBeInstanceOf(TypeError);
    expect(result.current.error.message).toBe("Failed to fetch");
    
  5. fetches nothing without a url
    const fetch = mockFetch(() => []);
    const { result } = renderHook(() => useFetch(null));
    await flush();
    expect(fetch.calls).toHaveLength(0);
    expect(result.current.loading).toBe(false);
    expect(result.current.data).toBeNull();
    expect(result.current.error).toBeNull();
    
  6. a new url starts again from no data
    const later = deferred();
    mockFetch((url) => (url === "/api/teas/1" ? { id: 1, name: "Sencha" } : later.promise));
    const { result, rerender } = renderHook(({ url }) => useFetch(url), { initialProps: { url: "/api/teas/1" } });
    await waitFor(() => expect(result.current.data).toEqual({ id: 1, name: "Sencha" }));
    rerender({ url: "/api/teas/2" });
    expect(result.current.data).toBeNull();
    expect(result.current.loading).toBe(true);
    
  7. a new url aborts the old request and ignores its late answer
    const first = deferred();
    const second = deferred();
    const fetch = mockFetch((url) => (url === "/api/teas/1" ? first.promise : second.promise));
    const { result, rerender } = renderHook(({ url }) => useFetch(url), { initialProps: { url: "/api/teas/1" } });
    rerender({ url: "/api/teas/2" });
    expect(fetch.calls[0].init.signal.aborted).toBe(true);
    second.resolve({ id: 2, name: "Gyokuro" });
    await waitFor(() => expect(result.current.data).toEqual({ id: 2, name: "Gyokuro" }));
    first.resolve({ id: 1, name: "Sencha" });
    await flush();
    expect(result.current.data).toEqual({ id: 2, name: "Gyokuro" });
    
  8. an aborted request never shows as an error
    const first = deferred();
    const second = deferred();
    mockFetch((url) => (url === "/api/teas/1" ? first.promise : second.promise));
    const { result, rerender } = renderHook(({ url }) => useFetch(url), { initialProps: { url: "/api/teas/1" } });
    rerender({ url: "/api/teas/2" });
    await flush();
    expect(result.current.error).toBeNull();
    expect(result.current.loading).toBe(true);
    
  9. refetch() asks again, keeping the old data while it waits
    const again = deferred();
    let requests = 0;
    const fetch = mockFetch(() => (++requests === 1 ? [{ id: 1, name: "Sencha" }] : again.promise));
    const { result } = renderHook(() => useFetch("/api/teas"));
    await waitFor(() => expect(result.current.loading).toBe(false));
    act(() => result.current.refetch());
    expect(fetch.calls).toHaveLength(2);
    expect(fetch.calls[1].url).toBe("/api/teas");
    expect(result.current.loading).toBe(true);
    expect(result.current.data).toEqual([{ id: 1, name: "Sencha" }]);
    again.resolve([{ id: 1, name: "Sencha" }, { id: 2, name: "Gyokuro" }]);
    await waitFor(() => expect(result.current.data).toHaveLength(2));
    expect(result.current.loading).toBe(false);
    
  10. in a component: loading, an error, then Try again
    function TeaMenu() {
      const { data, error, loading, refetch } = useFetch("/api/teas");
      if (error) return <button onClick={refetch}>Try again</button>;
      if (loading && !data) return <p>Loading…</p>;
      return (
        <ul>
          {data.map((tea) => (
            <li key={tea.id}>{tea.name}</li>
          ))}
        </ul>
      );
    }
    const again = deferred();
    let requests = 0;
    mockFetch(() => (++requests === 1 ? fail(503) : again.promise));
    render(<TeaMenu />);
    expect(screen.getByText("Loading…")).toBeInTheDocument();
    await user.click(await screen.findByRole("button", { name: "Try again" }));
    expect(screen.getByText("Loading…")).toBeInTheDocument();
    again.resolve([{ id: 1, name: "Sencha" }]);
    expect(await screen.findByText("Sencha")).toBeInTheDocument();
    expect(screen.queryByRole("button", { name: "Try again" })).not.toBeInTheDocument();
    

Learn more

useFetch.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).