5/25 lessons20%

Handling Async in Hooks (Without Going Mad)

Ah, async logic in React. The moment you toss an async function into useEffect, it’s like tossing Mentos into Coke—things bubble over fast. I’ve personally spent too many hours wondering why stale data showed up, why loading spinners stayed forever, or why my app kept yelling about memory leaks when navigating away. React doesn’t come with built-in async utilities, so we’re left inventing patterns. Some work great. Some... not so much. In this lesson, we’ll break down practical async hook patterns, cover the syntax, and show how to avoid the classic pitfalls (including the ones I walked straight into).

Async + useEffect = Headache (And That’s Just the Start)

Let’s get this out of the way: you can’t make the effect function itself async. If you’ve tried, you’ve seen this:

javascript
1
2
3
          useEffect(async () => {
  const res = await fetchData(); // ❌ React complains
}, []);
        

React expects the callback to return undefined or a cleanup function—not a Promise. Instead, do this:

javascript
1
2
3
4
5
6
7
          useEffect(() => {
  const fetchData = async () => {
    const res = await fetch('/api/data');
    setData(await res.json());
  };
  fetchData();
}, []);
        

Cleaner, safer. Still feels clunky though, right?

Now imagine needing to:

Track loading state. Handle errors. * Cancel the request if the component unmounts.

Suddenly you’re writing 40 lines of boilerplate in every component that fetches data. Which brings us to...

useAsync Hook Pattern Explained (This One Actually Helps)

Here’s a reusable hook pattern I use constantly. It wraps all the fetch-and-track logic into a nice, composable hook:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
          import { useEffect, useState } from 'react';

function useAsync(asyncFn, deps = []) {
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [value, setValue] = useState(null);

  useEffect(() => {
    let isActive = true;

    setLoading(true);
    setError(null);

    asyncFn()
      .then((result) => {
        if (isActive) setValue(result);
      })
      .catch((err) => {
        if (isActive) setError(err);
      })
      .finally(() => {
        if (isActive) setLoading(false);
      });

    return () => {
      isActive = false;
    };
  }, deps);

  return { loading, error, value };
}
        

Use it like this:

javascript
1
2
3
          const { loading, error, value: posts } = useAsync(() =>
  fetch('/api/posts').then(res => res.json()), []
);
        

Now your component can focus on what it wants to do, not how the fetch works. This hook abstracts the lifecycle so you don't have to keep reinventing the wheel.

Tracking Loading and Error States (Stop Reinventing These)

Back when I started with React, I used to set isLoading manually in each component. Then I’d forget to turn it off in the .catch block. Or forget to reset the error. Or worse, update state on an unmounted component (cue the memory leak warning).

Now? I use a pattern where every async hook returns:

`loading` – Boolean errorError object or null * value – Final resolved value

This is enough for 90% of UI feedback needs. Bonus: your component code becomes super readable.

javascript
1
2
3
          if (loading) return <Spinner />;
if (error) return <ErrorMessage msg={error.message} />;
return <PostList posts={value} />;
        

Feels clean, right? Because it is.

Cleaning Up Effects and Memory Leaks (Don’t Let Ghosts Haunt You)

The sneaky bug that haunted me the longest? Setting state after a component unmounted.

You click to another page. The fetch finishes. The setState still fires—and React gives you that charming warning about updating an unmounted component.

That’s why in our useAsync implementation, we track isActive:

javascript
1
2
3
4
5
6
7
8
          let isActive = true;

useEffect(() => {
  // ...
  return () => {
    isActive = false;
  };
}, []);
        

It’s a manual but reliable guard. Alternatively, you could use AbortController (and you should when dealing with fetch), but sometimes it’s overkill.

For larger apps, using something like SWR or React Query might be smarter. But even then, knowing the manual pattern helps you understand what those tools are doing under the hood.

Making Async Hooks Developer-Friendly (Because You’ll Use Them a Lot)

Okay, here’s the real trick: don’t just build a one-size-fits-all hook. Build focused async hooks.

Example: a hook just for fetching user data.

javascript
1
2
3
4
5
          function useUser(userId) {
  return useAsync(() =>
    fetch(`/api/users/${userId}`).then((res) => res.json()), [userId]
  );
}
        

Now inside your component:

javascript
1
          const { value: user, loading, error } = useUser(id);
        

You didn’t write a single line of fetch logic in the component. That’s the dream.

Want to be even fancier? Add caching, debouncing, retries. But honestly? Start simple. The best dev-friendly hook is the one people actually understand.

Next up: Context Is Not Global State (Please Stop That) We'll clear the confusion around Context and show why using it like Redux 2.0 is often the wrong move.