webdev.complete
🧩 Advanced Hooks & Patterns
βš›οΈReact
Lesson 85 of 117
25 min

Custom Hooks & Patterns

Compose hooks. Compound components. Headless UI.

Once you've written the same useState + useEffect pattern three times, you're ready for the most important React pattern nobody talks about loudly enough: custom hooks. They are the answer to "how do I share stateful logic between components?" In React, the answer is always "write a hook."

The pattern: a function that uses hooks

A custom hook is just a function whose name starts with useand which internally calls other hooks. That's the whole rule.

tsx
function useCounter(initial = 0) {
  const [count, setCount] = useState(initial);
  const increment = () => setCount((c) => c + 1);
  const decrement = () => setCount((c) => c - 1);
  const reset = () => setCount(initial);
  return { count, increment, decrement, reset };
}

// In a component
function App() {
  const { count, increment, reset } = useCounter(10);
  return <button onClick={increment}>{count}</button>;
}

Each call to useCounter creates its own independent state. Two components calling it get two separate counters. Hooks share logic, not state.

The rules of hooks

  1. Only call at the top of a component or another hook. Not inside if-statements, loops, or callbacks. React tracks hooks by their order per render. If the order changes, the hook state gets misaligned.
  2. Only call from React components or other hooks. Not from regular utility functions, not from event handlers, not from effects. The lint rule react-hooks/rules-of-hooks will yell at you.
  3. Name them useX. The linter uses the prefix to identify a hook and apply rule #1. Call it fetchDataand the linter won't protect you.

What good custom hooks do

Look for these signals that something should become a hook:

  • Multiple components have the same useState + useEffect dance.
  • A piece of logic involves a hook AND would otherwise be a utility function.
  • You're wiring up a browser API (geolocation, media query, online status).
  • You're abstracting a third-party library's side effects.
Hooks aren't about scope, they're about reuse
Don't extract a custom hook just because the function is long. Extract one when the same hook combo appears in two places. Premature hook extraction creates ghost abstractions you have to chase through files.

A useful real-world hook: useLocalStorage

tsx
function useLocalStorage(key, initial) {
  // Lazy init so we don't read localStorage on every render
  const [value, setValue] = useState(() => {
    try {
      const raw = localStorage.getItem(key);
      return raw !== null ? JSON.parse(raw) : initial;
    } catch {
      return initial;
    }
  });

  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch {}
  }, [key, value]);

  return [value, setValue];
}

// Same API as useState, but persists to localStorage
const [name, setName] = useLocalStorage("name", "Ada");

That's a custom hook earning its keep. You write it once, every component that needs persistence is one line of code, and the complexity (serialization, errors, lazy init) is hidden in one place.

Compound components

A pattern hooks make easy. Instead of one giant component with 20 props, you make a parent component plus child components that communicate via context. Consumers compose them.

tsx
<Tabs defaultValue="profile">
  <Tabs.List>
    <Tabs.Trigger value="profile">Profile</Tabs.Trigger>
    <Tabs.Trigger value="settings">Settings</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Panel value="profile">...</Tabs.Panel>
  <Tabs.Panel value="settings">...</Tabs.Panel>
</Tabs>

Internally, Tabs creates a context with the active value and a setter. Trigger and Panel subscribe via useContext. The hook is the glue. The user writes readable JSX.

Headless UI: the new ecosystem default

The biggest shift in React UI in 2026 is "headless" component libraries. Instead of opinionated styled widgets, Radix Primitives and React Aria give you accessible, unstyled, fully-functional building blocks. You style them however you want (typically with Tailwind or your design system). They handle the hard part: keyboard navigation, ARIA, focus traps, screen-reader support.

  • Radix Primitives: low-level dialogs, dropdowns, tabs, tooltips. Composable, headless. The shadcn/ui library is built on top.
  • React Aria: hooks first. Adobe's industrial-grade a11y library. useButton, useCheckbox, useDialog. Use the hooks and write your own markup.
  • shadcn/ui: not a library, a registry. You npx shadcn add button and the source lands in your repo. You own the code.

For a fresh React project in 2026, this is the default. You almost never build a date picker, dialog, or combobox from scratch anymore. You take a tested primitive and style it.

Try it: useLocalStorage in action

Type into the box. Refresh the iframe. Your text comes back. That's a custom hook hiding all the messy details.

import { useEffect, useState } from "react";

function useLocalStorage(key, initial) {
  const [value, setValue] = useState(() => {
    try {
      const raw = localStorage.getItem(key);
      return raw !== null ? JSON.parse(raw) : initial;
    } catch {
      return initial;
    }
  });

  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch {}
  }, [key, value]);

  return [value, setValue];
}

function useOnlineStatus() {
  const [online, setOnline] = useState(() => navigator.onLine);
  useEffect(() => {
    const on = () => setOnline(true);
    const off = () => setOnline(false);
    window.addEventListener("online", on);
    window.addEventListener("offline", off);
    return () => {
      window.removeEventListener("online", on);
      window.removeEventListener("offline", off);
    };
  }, []);
  return online;
}

export default function App() {
  const [name, setName] = useLocalStorage("user-name", "");
  const [count, setCount] = useLocalStorage("user-count", 0);
  const online = useOnlineStatus();

  return (
    <div style={{ padding: 24, fontFamily: "system-ui" }}>
      <h3>Persistent state</h3>
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="Type something, then reload"
        style={{ padding: 6, width: "100%", marginBottom: 8 }}
      />
      <div>
        Count: {count}{" "}
        <button onClick={() => setCount(count + 1)}>+1</button>{" "}
        <button onClick={() => setCount(0)}>Reset</button>
      </div>
      <p style={{ fontSize: 12, color: "#6b7280" }}>
        Network: {online ? "online" : "offline"}
      </p>
    </div>
  );
}

Quiz

Quiz1 / 3

What makes a function a &quot;hook&quot;?

Recap

  • A custom hook is a function named useSomethingthat calls other hooks. That's it.
  • Rules of hooks: top of the component or another hook, never inside conditionals/loops. Always called useX.
  • Each call creates its own state. Hooks share logic, not state.
  • Reach for them when you see the same hook combo in multiple places, or when you're wiring up a browser API.
  • Headless UI libraries (Radix, React Aria, shadcn/ui) are the modern baseline. Don't build dialogs from scratch in 2026.
Built with Next.js, Tailwind & Sandpack.
Learn. Build. Ship.