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.
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);
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();
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();
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");
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();
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);
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" });
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);
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);
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();
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).
I'm here to help with useFetch. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.