Lesson 11 — API Custom Hooks (useFetch / useAsync)

🧭 Introduction

Almost every real-world React application talks to an API.

But many codebases suffer from:

  • Repeated useEffect + fetch logic
  • Scattered loading and error handling
  • Inconsistent API behavior across components

This leads to:
❌ Messy components
❌ Hard-to-debug bugs
❌ Poor reusability

The professional solution is to extract API logic into custom hooks.

In this lesson, you’ll learn how to design clean, reusable API hooks using patterns like useFetch and useAsync.


🎯 What You’ll Learn in This Lesson

By the end of this lesson, you will understand:

  • Why API logic does not belong in components
  • How to design a reusable API hook
  • Managing loading, error, and data states
  • Handling dependency changes safely
  • Avoiding common API hook mistakes

❓ Why API Logic Should NOT Live in Components

❌ Typical Component Code

useEffect(() => {
  setLoading(true);
  fetch(url)
    .then(res => res.json())
    .then(setData)
    .catch(setError)
    .finally(() => setLoading(false));
}, [url]);

Problems:

  • Repeated everywhere
  • Hard to test
  • Clutters UI code

👉 Components should focus on what to render, not how data is fetched.


🧠 Mental Model (Very Important)

Components = UI
Hooks = Behavior / Logic

API calls are behavior, not UI.


🧩 Designing a Good API Hook (Checklist)

A good API hook should:

✅ Be reusable
✅ Accept parameters (URL, options)
✅ Manage loading, error, and data
✅ Handle dependency changes
✅ Clean up properly
❌ Not assume UI structure


🧪 Building useFetch (Step-by-Step)

Step 1: Basic Structure

import { useEffect, useState } from "react";

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    setError(null);

    fetch(url)
      .then(res => {
        if (!res.ok) throw new Error("Request failed");
        return res.json();
      })
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [url]);

  return { data, loading, error };
}


🔍 Using useFetch

function Users() {
  const { data, loading, error } = useFetch("/api/users");

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error!</p>;

  return (
    <ul>
      {data.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

👉 Component stays clean and declarative.


⚠ Handling Dependency Changes Correctly

Whenever url changes:

  • Effect re-runs
  • Data refetches

This is expected and desired.

⚠️ Never suppress dependencies just to “fix warnings”.


🚨 Avoiding Race Conditions (Advanced Insight)

If the URL changes quickly:

  • Older requests may resolve later
  • State may update incorrectly

🔍 Safe Pattern with AbortController

useEffect(() => {
  const controller = new AbortController();

  fetch(url, { signal: controller.signal })
    .then(res => res.json())
    .then(setData)
    .catch(err => {
      if (err.name !== "AbortError") {
        setError(err);
      }
    });

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

👉 Prevents stale updates and memory leaks.


🔁 Introducing useAsync (More Flexible Pattern)

useFetch works well for GET requests, but real apps need:

  • POST
  • PUT
  • DELETE
  • Manual triggers

This is where useAsync shines.


🧩 Building useAsync Hook

import { useCallback, useState } from "react";

function useAsync(asyncFunction) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const execute = useCallback(async (...args) => {
    setLoading(true);
    setError(null);

    try {
      const result = await asyncFunction(...args);
      setData(result);
      return result;
    } catch (err) {
      setError(err);
      throw err;
    } finally {
      setLoading(false);
    }
  }, [asyncFunction]);

  return { execute, data, loading, error };
}


🔍 Using useAsync

const fetchUsers = () => fetch("/api/users").then(res => res.json());

function Users() {
  const { execute, data, loading, error } = useAsync(fetchUsers);

  useEffect(() => {
    execute();
  }, [execute]);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error!</p>;

  return <div>{JSON.stringify(data)}</div>;
}

👉 Powerful, flexible, reusable.


🆚 useFetch vs useAsync (Decision Guide)

ScenarioUse
Simple GET requestuseFetch
Manual triggeruseAsync
POST / PUT / DELETEuseAsync
Complex workflowsuseAsync

🚨 Common Mistakes with API Hooks

❌ Mistake 1: Hardcoding URLs

Always pass URL or function as parameter.


❌ Mistake 2: No Cleanup

Leads to memory leaks.


❌ Mistake 3: Mixing UI Logic

Hooks should not return JSX.


❌ Mistake 4: Ignoring Errors

Always expose error state.


🧠 Real-World Insight

In large apps:

  • Custom hooks handle client-side API logic
  • Libraries like React Query handle server state

You must understand both.


🎯 Best Practices (Senior-Level)

✅ Keep hooks generic
✅ Expose predictable APIs
✅ Handle loading and errors
✅ Abort stale requests
✅ Prefer composition over duplication


❓ FAQs — API Custom Hooks

🔹 Should every API call use a custom hook?

Yes, if logic repeats or grows complex.


🔹 Can hooks replace Axios interceptors?

No — they complement each other.


🔹 Is useFetch enough for production apps?

For small apps — yes.
For large apps — consider React Query.


🔹 Should hooks handle retries?

Sometimes — but keep responsibility clear.


🧠 Quick Recap

✔ API logic belongs in hooks
✔ useFetch handles simple GET requests
✔ useAsync handles flexible async flows
✔ Cleanup prevents bugs
✔ Clean APIs scale better


🎉 Conclusion

Well-designed API hooks are a game-changer.

Once you master them:

  • Components stay clean
  • Logic becomes reusable
  • Apps scale gracefully

This lesson completes your mastery of API-based custom hooks ⚛️🧠


👉 Next Lesson

Lesson 12 — Authentication Custom Hook (useAuth)

Leave a Comment