Week 10

Refs

Custom hooks

React 19

React Server Components

Practice

Assignment

Front end Track

Customizing your hooks

As your React components grow, you'll notice patterns repeating: fetch some data, track whether the user is online, sync a value to localStorage. The logic looks the same, just with different details. You could copy and paste it, but there's a better way: extract it into a custom hook.

A custom hook is a function that starts with use and calls other hooks inside it. That's the entire definition. No special API, no registration. Just a naming convention that lets React apply its rules correctly and signals to other developers that this function contains stateful logic.

Extracting Repeated Logic into a Hook

Here's a concrete example. You're building a dashboard that shows the user's connection status in two places: a status bar at the top and a save button that disables itself when offline.

Without a custom hook, you'd duplicate the same state and effect in both components:

// StatusBar.tsx
function StatusBar() {
  const [isOnline, setIsOnline] = useState(true);

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  return <p>{isOnline ? 'Connected' : 'Disconnected'}</p>;
}

// SaveButton.tsx โ€” identical effect, different UI
function SaveButton() {
  const [isOnline, setIsOnline] = useState(true);

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  return (
    <button disabled={!isOnline}>
      {isOnline ? 'Save' : 'Reconnecting...'}
    </button>
  );
}

Both components work, but the duplication is a problem. If you need to fix a bug or change how online status is detected, you'd need to update both places and remember that they're connected.

The fix is to move the shared logic into a hook:

// hooks/useOnlineStatus.ts
import { useState, useEffect } from 'react';

export function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(true);

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  return isOnline;
}

Now both components become simple:

function StatusBar() {
  const isOnline = useOnlineStatus();
  return <p>{isOnline ? 'Connected' : 'Disconnected'}</p>;
}

function SaveButton() {
  const isOnline = useOnlineStatus();
  return (
    <button disabled={!isOnline}>
      {isOnline ? 'Save' : 'Reconnecting...'}
    </button>
  );
}

The components no longer describe how to detect network status: they just describe what they want to know. That's the real benefit of custom hooks: they let your components express intent rather than implementation.

Custom Hooks Share Logic, Not State

It's tempting to think of useOnlineStatus as a shared piece of state. It isn't. Each component that calls useOnlineStatus gets its own independent state and effect. They happen to show the same value because they're both listening to the same browser events (not because they share anything).

This matters because it means custom hooks are safe to use in multiple components without any risk of one component's state bleeding into another.

The Naming Convention

Hook names must start with use followed by a capital letter: useOnlineStatus, useFetch, useDebounce. This isn't just a style preference โ€” React's linter uses this convention to enforce the rules of hooks. If your function doesn't start with use, the linter won't warn you if you accidentally call a hook inside a condition or a loop.

<aside> ๐Ÿ’ก

Info

The naming convention also makes hooks easy to spot in code review. When you see useOnlineStatus() called in a component, you immediately know it contains stateful logic. A function called getOnlineStatus() carries no such signal (and the linter won't protect you if you misuse it).

</aside>

The Rules of Hooks

Custom hooks follow the same rules as built-in hooks. React enforces these through the eslint-plugin-react-hooks linter (which Next.js includes by default) but it's worth understanding why the rules exist, not just what they are.

Only call hooks at the top level. Never inside conditions, loops, or nested functions:

// ๐Ÿ”ด Wrong โ€” hook is called conditionally
function UserProfile({ userId }: { userId: string | null }) {
  if (!userId) return null;

  // React can't guarantee this hook runs on every render
  const state = useFetch<User>(`/api/users/${userId}`);
  return <h1>{state.data?.name}</h1>;
}

// โœ… Correct โ€” call the hook first, handle the condition in the return
function UserProfile({ userId }: { userId: string | null }) {
  const state = useFetch<User>(userId ? `/api/users/${userId}` : '');

  if (!userId) return null;
  if (state.status === 'loading') return <p>Loading...</p>;
  if (state.status === 'error') return <p>Error: {state.message}</p>;

  return <h1>{state.data.name}</h1>;
}

Only call hooks from React functions. Hooks can be called from components or other custom hooks, not from regular JavaScript functions, event handlers, or class components.

Why These Rules Exist

React tracks hooks by the order they're called. Every time a component renders, React expects to see the same hooks called in the same sequence. It uses this order to match each hook to its state from the previous render.

If you call a hook inside a condition, that hook might be skipped on some renders and called on others. React would then try to match a hook from this render against the wrong state from the previous render โ€” leading to bugs that are genuinely hard to diagnose.

// On the first render, React sees:
// 1. useState (theme)
// 2. useFetch (user data)
// 3. useLocalStorage (preferences)

// If a condition skips useFetch on the second render, React sees:
// 1. useState (theme)
// 2. useLocalStorage โ€” but React thinks this is still useFetch!

This is why the linter is strict about it. It's not a stylistic preference โ€” it's protecting the internal consistency of React's rendering model. When the linter flags a conditional hook call, it's telling you that you have a structural problem to solve, not a rule to work around.

<aside> โš ๏ธ

Warning

Never use // eslint-disable-next-line react-hooks/rules-of-hooks to suppress hook warnings. If the linter is complaining, restructure the code so the hook is called unconditionally. Usually this means moving the condition into the hook's logic or into the JSX return rather than around the hook call itself.

</aside>

Building Practical Hooks

The best way to understand custom hooks is to build ones you'd actually use.

useFetch

Data fetching is the most common thing to extract. This hook handles loading, error, and success states and cancels stale requests automatically:

// hooks/useFetch.ts
import { useState, useEffect } from 'react';

type FetchState<T> =
  | { status: 'loading' }
  | { status: 'error'; message: string }
  | { status: 'success'; data: T };

export function useFetch<T>(url: string) {
  const [state, setState] = useState<FetchState<T>>({ status: 'loading' });

  useEffect(() => {
    const controller = new AbortController();
    setState({ status: 'loading' });

    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json() as Promise<T>;
      })
      .then((data) => setState({ status: 'success', data }))
      .catch((err) => {
        if (err.name !== 'AbortError') {
          setState({ status: 'error', message: err.message });
        }
      });

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

  return state;
}

Any component that needs to fetch data can now do it in one line:

interface Post {
  id: number;
  title: string;
  body: string;
}

function BlogPost({ postId }: { postId: number }) {
  const state = useFetch<Post>(`https://jsonplaceholder.typicode.com/posts/${postId}`);

  if (state.status === 'loading') return <p>Loading...</p>;
  if (state.status === 'error') return <p>Error: {state.message}</p>;

  return (
    <article>
      <h1>{state.data.title}</h1>
      <p>{state.data.body}</p>
    </article>
  );
}

useDebounce

Debouncing delays a value update until the user stops changing it. Useful for search inputs where you don't want to fire a request on every keystroke:

// hooks/useDebounce.ts
import { useState, useEffect } from 'react';

export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}
function BookSearch() {
  const [query, setQuery] = useState('');
  // Only updates debouncedQuery 300ms after the user stops typing
  const debouncedQuery = useDebounce(query, 300);

  // This fetch only runs when debouncedQuery changes โ€” not on every keystroke
  const state = useFetch<Book[]>(
    `https://openlibrary.org/search.json?q=${encodeURIComponent(debouncedQuery)}&limit=5`
  );

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search for books..."
      />
      {state.status === 'success' && (
        <ul>
          {state.data.map((book) => (
            <li key={book.key}>{book.title}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

Notice how useFetch and useDebounce compose naturally. The output of useDebounce feeds directly into useFetch. This is one of the most useful properties of custom hooks โ€” you can chain them together, with each one focused on a single concern.

<aside> โŒจ๏ธ

Hands On

Take a component you've already written that fetches data directly with useEffect and useState. Extract the fetch logic into a useFetch hook. The component should end up with no useEffect and no fetch-related state โ€” just a call to useFetch and the rendering logic. Notice how much easier the component becomes to read.

</aside>

When to Extract a Hook

You don't need a custom hook for every piece of logic. A single useState call doesn't need to be extracted. A short, self-contained useEffect that's only used once is fine where it is.

The signal to extract is when a component has multiple related pieces of stateful logic that belong together โ€” especially when that logic involves an effect. Effects are the most common candidate because they're the most complex, and wrapping one in a hook gives it a name that explains what it does.

A practical way to think about it: if you found yourself writing a comment above a block of state and effects to explain what they're doing together, that's a hook waiting to be named.

// Before โ€” the comment is doing the work a hook name should do
function ProductPage({ productId }: { productId: string }) {
  // Track whether the product is in the user's wishlist
  // and sync it to localStorage
  const [isWishlisted, setIsWishlisted] = useState(() => {
    const stored = localStorage.getItem(`wishlist-${productId}`);
    return stored === 'true';
  });

  const toggleWishlist = () => {
    const next = !isWishlisted;
    setIsWishlisted(next);
    localStorage.setItem(`wishlist-${productId}`, String(next));
  };

  // ... rest of the component
}

// After โ€” the hook name replaces the comment
function ProductPage({ productId }: { productId: string }) {
  const [isWishlisted, toggleWishlist] = useWishlist(productId);

  // ... rest of the component
}

Patterns You'll See in Modern Codebases

Most professional React codebases have a hooks/ folder with a small library of custom hooks. Some patterns that appear consistently:

Data fetching hooks โ€” wrapping fetch logic with loading/error/success state is so common that most teams extract it immediately. Libraries like TanStack Query essentially give you a highly optimised version of useFetch with caching, background refetching, and much more. Understanding how to build the pattern yourself is what lets you understand and use those libraries effectively.

Browser API hooks โ€” browser APIs like localStorage, sessionStorage, matchMedia (for detecting dark mode), IntersectionObserver (for detecting when elements scroll into view), and ResizeObserver (for tracking element dimensions) all follow the same shape: subscribe in an effect, clean up on unmount, expose a clean value. Custom hooks are the natural home for all of them.

Form state hooks โ€” managing the state, validation, and submission logic for a form is repetitive work. Extracting it into a useForm hook (or using a library like React Hook Form, which is itself built on this pattern) keeps components focused on their layout rather than their state management.

Feature-specific hooks โ€” hooks don't have to be generic. A useCart, useAuth, or useNotifications hook that encapsulates the state and logic for a specific feature of your application is a completely valid use of the pattern. These hooks often compose several generic hooks internally.

Hooks Make Future Migrations Easier

One underrated benefit: when logic lives in a hook, it's easier to swap the implementation without touching the components that use it.

The React ecosystem moves quickly. Libraries come along that do things better than hand-rolled effects: TanStack Query for data fetching, Jotai or Zustand for state management, React 19's new primitives for async. If your data fetching is scattered across components as raw useEffect calls, migrating to a better solution requires finding and updating every one of them. If it's in a useFetch hook, you update the hook and every component gets the improvement automatically.

The React docs make this point directly in the context of useOnlineStatus: when useSyncExternalStore became available as a better primitive for subscribing to external data sources, teams who had wrapped their logic in a custom hook could update in one place. Teams who hadn't faced a much larger refactor.

<aside> โš ๏ธ

Warning

Avoid creating hooks named after lifecycle moments: useMount, useOnUpdate, useUnmount. These are usually a sign that the logic inside is tied too closely to React's internals rather than the external system it's actually interacting with. Name hooks after what they do, not when they run: useOnlineStatus, useDocumentTitle, useDebounce.

</aside>

Additional Resources

Videos

Reading

Tools


The HackYourFuture curriculum is licensed underย CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

CC BY-NC-SA 4.0 Icons

Built with โค๏ธ by the HackYourFuture community ยท Thank you, contributors

Found a mistake or have a suggestion? Let us know in the feedback form.