DEV Community

BlackJosh007
BlackJosh007

Posted on

A Fetch Hook Bug Taught Me When (Not) to Use 6 React Concepts

This week I stopped learning React APIs and started learning their boundaries.

Knowing what useContext does is easy. Knowing when it's the wrong tool is the real skill. So this post covers six concepts, each with a "use it when" and a "not when". We'll start with the one that humbled me.

1. Custom hooks (and when a plain helper is better)

A custom hook reuses logic, not UI. Instead of repeating the same state and effect logic across components, you pull it into a function that starts with use and import it where you need it.

One thing that's easy to get wrong: hooks don't magically share state. Every call to a hook creates its own state. Two components calling useProduct(1) each get their own separate product.

Use a custom hook when the logic calls React hooks (useState, useEffect, useContext...) and you need it in more than one place.

Use a plain helper function when the logic calls no hooks. Two quick cases:

  • Formatting 1500 as "₦1,500" is a helper.
  • Reading the current theme from Context to pick a button color is a custom hook. It has no state of its own, but it calls useContext.

So the test isn't "does it have state?" but "does it call a hook?". A helper also works outside React (in a backend or a test), and unlike a hook it can be called inside loops and conditions.

To practise, I wrote useProduct, a hook that fetches a product by ID. It worked. Then I traced it line by line.

2. AbortController (and the two bugs I found by tracing)

AbortController cancels an async operation such as a fetch. You create a controller, pass controller.signal to fetch, and call controller.abort() to cancel.

Use it when a request can become irrelevant before it finishes, like a user moving to another page, or switching from product 1 to product 2 mid-request. Without it, the old request keeps running and can still update your UI.

Here was my first version's finally block:

} finally {
  setLoading(false);
}
Enter fullscreen mode Exit fullscreen mode

Bug 1: a stale request touching state. I changed productId from 1 to 2 while request 1 was in flight. The cleanup aborted request 1, but its finally still ran setLoading(false) while request 2 was loading. The UI said "done" while it was still working.

Bug 2: I never checked response.ok. fetch only rejects when it gets no response at all (a network failure or an abort). A 404 resolves normally, so I was storing an error body as a "product".

While fixing bug 2, I found out that response.statusText is empty over HTTP/2, so matching on it silently depends on the protocol. response.status, the number, is the reliable signal.

Here's the version that handles both:

import { useState, useEffect } from "react";

export default function useProduct(productId) {
  const [product, setProduct] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!productId) {
      setProduct(null);
      setLoading(false);
      setError(null);
      return;
    }

    const controller = new AbortController();
    const { signal } = controller;

    const fetchData = async () => {
      setLoading(true);
      setError(null);
      setProduct(null);

      try {
        const response = await fetch(
          `https://dummyjson.com/products/${productId}`,
          { signal }
        );

        if (!response.ok) {
          const httpError = new Error(`Request failed with status ${response.status}`);
          httpError.status = response.status;
          throw httpError;
        }

        const result = await response.json();
        setProduct(result);
      } catch (err) {
        if (err.name === "AbortError") return;

        if (err.status === 404) {
          setError("Product not found");
        } else if (err.status >= 500) {
          setError("Server error occurred");
        } else {
          setError("An error occurred while fetching the product");
        }
      } finally {
        if (!signal.aborted) setLoading(false);
      }
    };

    fetchData();

    return () => controller.abort();
  }, [productId]);

  return { product, loading, error };
}
Enter fullscreen mode Exit fullscreen mode

Three decisions in there:

  • An aborted request does nothing to state. An abort always comes from the cleanup, so whatever triggered it (a new productId, a null ID, or an unmount) owns what happens to loading next.
  • I attach status to the error itself. fetch doesn't throw on a 404, so I throw it myself and carry the number to catch, instead of matching on message strings that can drift apart.
  • I reset product to null on every fetch. That means the UI flashes empty between products. I chose that over showing the previous product's data while the new one loads.

Not when: aborting stops you from waiting for a response. It doesn't undo something the server already did, so it's not a safety net for a POST that has already reached the server.

The real lesson: both bugs came from tracing the order of events, not from knowing the API. I need to be able to simulate how my code runs, step by step, to catch behavior I didn't intend. I still need to work on that.

3. Composition (vs extracting components)

Composition means wrapping a component around other content. The parent doesn't need to know what's inside it.

function Card({ children }) {
  return <div className="rounded-lg bg-gray-200 p-2">{children}</div>;
}

<Card>
  <UserProfile />
</Card>
Enter fullscreen mode Exit fullscreen mode

The rule I registered: does this piece have a distinct responsibility that would make the parent easier to understand if extracted?

Use it when the answer is yes. Not when it's no, because then you're only scattering code across files for the sake of it.

4. Derived state (vs stale state)

Two rules:

  1. Store the source of truth, then derive everything else from it.
  2. If a value can be calculated from current props or state during render, derive it instead of storing it.
// Stale-prone: a copy that must be kept in sync by hand
const [items, setItems] = useState([]);
const [count, setCount] = useState(0);

// Derived: always correct
const [items, setItems] = useState([]);
const count = items.length;
Enter fullscreen mode Exit fullscreen mode

Stale state is what you get when a copy of a value stops matching the original.

Use state when the value is a source of truth, such as user input or fetched data. Not when it can be computed from something you already have.

5. Context (vs props)

For a long time I wondered: if a deeply nested component needs some data, must I pass it as props through every component in between, even the ones that don't use it? Context solves that. It lets you share data across a component subtree without passing it through every intermediate component.

I first called it "global state", but that's wrong. It's a way to share data, not to manage it.

Use it when many components at different depths need the same data and it changes rarely, like the theme or the current user.

Not when the data updates frequently, such as high-frequency input like typing in a form. Every component consuming a context re-renders whenever the provider's value changes, so a value that changes on every keystroke makes every consumer re-render on every keystroke. And if the data only travels a level or two, props are fine.

6. useReducer (vs useState)

Instead of scattering state logic across event handlers, useReducer centralizes it in one function.

const initialState = { name: "" };

function userReducer(state, action) {
  switch (action.type) {
    case "set_name":
      return { ...state, name: action.payload };
    case "reset":
      return { name: "" };
    default:
      return state;
  }
}

const [userState, userDispatch] = useReducer(userReducer, initialState);

// in a handler
userDispatch({ type: "set_name", payload: e.target.value });
Enter fullscreen mode Exit fullscreen mode

Use useState when state is simple and its updates are simple. Consider useReducer when several related state changes happen together and the update logic benefits from living in one place.

Recap: concepts learnt this week

  • Custom hooks reuse logic and each call gets its own state; helpers are for logic with no hooks.
  • AbortController cancels async work; an aborted request shouldn't touch state.
  • Composition lets a parent wrap content it doesn't know about; extract only when the piece has a distinct responsibility.
  • Derived state: store the source of truth, derive the rest.
  • Context shares data across a subtree; avoid it for fast-changing values.
  • useReducer centralizes related state updates; useState is enough for simple ones.

What I'm enjoying most is that I'm not just learning new React APIs, I'm learning their boundaries: when to reach for something, and when not to. I can't wait to start building realistic frontend applications.

What's a rule you wish you'd learnt earlier? Tell me in the comments.

Top comments (0)