Skip to main content

πŸͺ Custom Hooks

A great chef perfects a signature sauce once, then reuses it across dozens of dishes. Custom hooks are your recipes: you extract a chunk of stateful component logic β€” fetching, storing to localStorage, tracking window size β€” into a plain function, and reuse it everywhere without copy-pasting.

Week 5 · Monday: React Hooks Deep Dive · Lecture 3

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Define what a custom hook is and the rules every hook must follow
  • Extract duplicated component logic into a reusable use* function
  • Build the workhorses: useFetch, useLocalStorage, useDebounce
  • Compose hooks β€” building bigger hooks from smaller ones
  • Return a consistent, predictable shape from every hook
  • Test a custom hook in isolation

Estimated Time: 65 minutes

Practice: Extract a useFetch hook, write a persistent useLocalStorage, and compose them into a useAuth.

In This Lesson

What Is a Custom Hook?

A custom hook is simply a JavaScript function whose name starts with use and that calls other hooks (useState, useEffect, even other custom hooks). It isn't a new React feature β€” it's a convention that lets you lift stateful logic out of a component and share it, the same way a regular function lets you share plain logic.

graph TD A[Logic duplicated across components] --> B[Extract into a use* function] B --> C[Reusable Custom Hook] C --> D[Component 1] C --> E[Component 2] C --> F[Component 3]

⚠️ The Rules of Hooks apply

  • Names must start with use β€” that's how React's linter knows to enforce the rules
  • Call hooks only at the top level β€” never inside conditions, loops, or nested functions
  • Call hooks only from React functions β€” components or other hooks

A custom hook shares logic, not state. Two components using the same hook each get their own independent state.

Extracting Your First Hook

Here's the classic duplication: every component that loads data repeats the same loading / error / data dance.

// ❌ Repeated in every data-loading component
function ProductList() {
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch('/api/products')
      .then(res => res.json())
      .then(data => { setData(data); setLoading(false); })
      .catch(err => { setError(err); setLoading(false); });
  }, []);

  if (loading) return <div>Loading…</div>;
  if (error) return <div>Error: {error.message}</div>;
  return <div>{/* render products */}</div>;
}

Lift the three state pieces and the effect into useFetch. The modern version adds cleanup so a response that arrives after the component unmounts (or after url changes) is ignored β€” a common source of "can't update state on an unmounted component" warnings.

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

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    setError(null);

    fetch(url, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(json => { setData(json); setLoading(false); })
      .catch(err => {
        if (err.name === 'AbortError') return; // ignore cancelled fetch
        setError(err);
        setLoading(false);
      });

    // Cleanup: cancel the request if url changes or component unmounts.
    return () => controller.abort();
  }, [url]);

  return { data, loading, error };
}

// βœ… Now the component is tiny and declarative
function ProductList() {
  const { data, loading, error } = useFetch('/api/products');
  if (loading) return <div>Loading…</div>;
  if (error) return <div>Error: {error.message}</div>;
  return <div>{/* render products */}</div>;
}
Duplicated logic in three components is extracted into one shared custom hook ProductList fetch + state UserCard fetch + state OrderView useFetch(url) { data, loading, error }
One hook, many consumers. Each component calls useFetch and gets its own independent { data, loading, error }.

useLocalStorage

A hook that behaves exactly like useState but transparently persists to localStorage β€” so a preference survives a page refresh. This is the canonical "extract reusable logic" example.

function useLocalStorage(key, initialValue) {
  // Lazy initializer: read localStorage only once, on mount.
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.warn(`Error reading localStorage key "${key}":`, error);
      return initialValue;
    }
  });

  // A setter with the same API as useState (value OR updater function).
  const setValue = (value) => {
    try {
      const valueToStore =
        value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.warn(`Error setting localStorage key "${key}":`, error);
    }
  };

  return [storedValue, setValue];
}

// Usage β€” identical feel to useState, but it remembers.
function DarkModeToggle() {
  const [isDark, setIsDark] = useLocalStorage('darkMode', false);
  return (
    <button onClick={() => setIsDark(prev => !prev)}>
      {isDark ? '🌞 Light Mode' : 'πŸŒ™ Dark Mode'}
    </button>
  );
}

πŸ’‘ Why the lazy initializer

Passing a function to useState means React calls it only on the first render, not on every render. Reading and parsing localStorage is comparatively slow, so you do it once β€” not on every re-render.

useDebounce & useWindowSize

useDebounce β€” wait for the typing to stop

Firing an API call on every keystroke floods your server. Debouncing returns a value that only updates after the user pauses. It's a tiny hook with an outsized impact on search boxes.

function useDebounce(value, delay = 500) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => setDebounced(value), delay);
    // If value changes before `delay`, cancel the pending update.
    return () => clearTimeout(handler);
  }, [value, delay]);

  return debounced;
}

function SearchBox() {
  const [term, setTerm] = useState('');
  const debouncedTerm = useDebounce(term, 400);

  useEffect(() => {
    if (debouncedTerm) searchAPI(debouncedTerm); // runs 400ms after typing stops
  }, [debouncedTerm]);

  return (
    <input
      value={term}
      onChange={(e) => setTerm(e.target.value)}
      placeholder="Search…"
    />
  );
}

useWindowSize β€” subscribe to the viewport

Any hook that adds an event listener must remove it on cleanup, or you leak listeners every time the component mounts.

function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    const handleResize = () =>
      setSize({ width: window.innerWidth, height: window.innerHeight });

    window.addEventListener('resize', handleResize);
    handleResize(); // sync once on mount
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return size;
}

function Responsive() {
  const { width } = useWindowSize();
  return width < 768 ? <MobileView /> : <DesktopView />;
}

Composing Hooks

The real superpower: a custom hook can call other custom hooks. Here useAuth is built entirely out of the smaller hooks we've written, layering behavior instead of rewriting it.

function useAuth() {
  // Persist the token across refreshes with our own hook…
  const [token, setToken] = useLocalStorage('token', null);
  const [user, setUser] = useLocalStorage('user', null);
  // …and reuse useFetch to load the current user when a token exists.
  const { data: freshUser, loading } = useFetch(
    token ? `/api/me?token=${token}` : null
  );

  const login = async (email, password) => {
    const res = await authAPI.login(email, password);
    setToken(res.token);
    setUser(res.user);
  };

  const logout = () => {
    setToken(null);
    setUser(null);
  };

  return {
    user: freshUser ?? user,
    isAuthenticated: Boolean(token),
    loading,
    login,
    logout,
  };
}

// A whole auth system in one line per component.
function App() {
  const { isAuthenticated, loading } = useAuth();
  if (loading) return <LoadingScreen />;
  return isAuthenticated ? <Dashboard /> : <LoginPage />;
}

βœ… Small hooks, big systems

Each hook does one thing well: useLocalStorage persists, useFetch loads. useAuth just orchestrates them. This is the same "compose small pieces" philosophy you'll use for components β€” applied to logic.

Testing Custom Hooks

Because a hook is just logic, you can test it without rendering a real UI. React Testing Library's renderHook mounts the hook in a throwaway component and act flushes updates.

// useCounter.js
function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  return {
    count,
    increment: () => setCount(c => c + 1),
    decrement: () => setCount(c => c - 1),
    reset: () => setCount(initialValue),
  };
}

// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

test('increments the count', () => {
  const { result } = renderHook(() => useCounter(10));
  expect(result.current.count).toBe(10);

  act(() => result.current.increment());
  expect(result.current.count).toBe(11);
});

test('resets to the initial value', () => {
  const { result } = renderHook(() => useCounter(5));
  act(() => { result.current.increment(); result.current.reset(); });
  expect(result.current.count).toBe(5);
});

πŸ’‘ act wraps state updates

Any call that triggers a state change must run inside act(...) so React can process the update before you assert on it. Forgetting act is the #1 cause of flaky hook tests.

Practice & Quiz

πŸ‹οΈ Exercise 1: useToggle

Goal: Write a useToggle(initial = false) hook returning [on, toggle], where toggle() flips the boolean and toggle(true/false) sets it explicitly.

πŸ’‘ Hint

Use useState plus useCallback so the returned function is stable. Inside it, check typeof value === 'boolean' to decide between setting and flipping.

βœ… Solution
function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback((value) => {
    setOn(prev => (typeof value === 'boolean' ? value : !prev));
  }, []);
  return [on, toggle];
}

πŸ‹οΈ Exercise 2: usePrevious

Goal: Write a usePrevious(value) hook that returns the value from the previous render (useful for comparing "old vs new").

βœ… Solution
function usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    // Runs AFTER render, so ref holds the prior value during render.
    ref.current = value;
  }, [value]);
  return ref.current;
}

// Usage
function Counter({ count }) {
  const prev = usePrevious(count);
  return <p>Now: {count}, before: {prev ?? 'β€”'}</p>;
}

🎯 Quick Quiz

Question 1: What makes a function a "custom hook"?

Question 2: Two components call the same useCounter() hook. Their counts are…

Question 3: Why does useDebounce return a cleanup that calls clearTimeout?

Best Practices & Pitfalls

βœ… Do

  • Name every hook use* so the linter enforces the Rules of Hooks
  • Keep each hook focused on one responsibility
  • Return a consistent shape every render (array like useState, or a stable object)
  • Clean up listeners, timers, and requests in the effect's return function
  • Compose small hooks into larger ones instead of duplicating logic

❌ Don't

  • Call hooks conditionally or inside loops β€” always top level
  • Run side effects directly in the hook body (they belong in useEffect)
  • Return different shapes on different renders ({ loading } then { user })
  • Expect two components to share state through one hook β€” use Context for that

⚠️ Inconsistent returns break consumers

// ❌ Bad: shape changes β†’ destructuring in the component breaks
function useAuth() {
  if (loading) return { loading };
  if (user) return { user };
  return null;
}

// βœ… Good: same keys every time
function useAuth() {
  return { user, loading, error };
}

Consumers destructure your return value. If the keys come and go, their code throws. Always return the full shape.

Summary

πŸŽ‰ Key Takeaways

  • A custom hook is a use* function that calls other hooks to share logic
  • Extract duplicated useState/useEffect logic into hooks like useFetch and useLocalStorage
  • Hooks share logic, not state β€” each caller gets independent state
  • Compose small hooks into bigger ones (useAuth from useFetch + useLocalStorage)
  • Always clean up effects and return a consistent shape; test hooks with renderHook

πŸ“š Additional Resources

πŸš€ What's Next?

You've completed the hooks deep dive β€” useContext, useReducer, and custom hooks. Next we start assembling multi-page apps: Client-Side Routing Concepts, where the URL drives which component renders without a full page reload.

πŸŽ‰ Hooks mastered!

Reusable, testable logic is what separates tidy React codebases from tangled ones. You can build those tools now.