C.W.K.
Stream
Lesson 04 of 07 · published

Custom Hooks — Sharing Stateful Logic

~15 min · custom-hooks, abstraction, reuse

Level 0React Novice
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
A custom hook is just a function whose name starts with use and which calls other hooks. That's the whole rule. The naming convention enables the linter to enforce the Rules of Hooks on it.

When you've earned a custom hook

Two or more components doing the same hook-based work. The duplication isn't in the JSX — it's in the useState + useEffect + cleanup dance. Extract that dance into a function. The two components now call the function and stay focused on rendering.

Naming

useChat, useMousePosition, useDebouncedValue, useLocalStorage. The use prefix is mandatory — it tells the React linter that this function follows the Rules of Hooks (must be called from the top level, never inside conditions). Without the prefix, the linter can't enforce.

What custom hooks return

Anything you want — usually an object or a tuple. The shape is the hook's API contract:

  • A single value: const width = useWindowWidth().
  • A tuple: const [value, setValue] = useLocalStorage(key, initial) — mirrors useState's shape.
  • An object: const { messages, send, isStreaming } = useChat(conversationId) — when there are multiple named outputs.

Use an object once you have more than two return values. Named fields beat positional ordering for readability.

The cwkPippa example

cwkPippa's useChat is a real custom hook that wraps a SQLite-backed conversation in a typed contract: messages, send, regenerate, isStreaming, error. Components don't know about fetch URLs, message parent IDs, or SSE parsing — they just call useChat(conversationId) and read the returned object. Track 5 lesson 7 walks the implementation; this lesson is the why-and-shape.

Two callers, one hook. The 'rule of two' applies. If only one component needs this logic, you might be over-engineering by extracting. Wait until the second caller arrives, then refactor. Premature hook extraction is the React-side cousin of premature abstraction in OOP.

Code

useLocalStorage — sync state with localStorage·tsx
import { useEffect, useState } from "react";

export function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, (v: T) => void] {
  // Lazy initializer reads localStorage once.
  const [value, setValue] = useState<T>(() => {
    try {
      const raw = localStorage.getItem(key);
      return raw ? (JSON.parse(raw) as T) : initialValue;
    } catch {
      return initialValue;
    }
  });

  // Write back to localStorage whenever value changes.
  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // Quota exceeded or storage disabled — silently ignore.
    }
  }, [key, value]);

  return [value, setValue];
}

// Usage — exactly like useState, but persists across reloads.
function ThemePicker() {
  const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "dark");
  return (
    <button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
      Switch to {theme === "dark" ? "light" : "dark"}
    </button>
  );
}
useDebouncedValue — a tiny, frequently-needed hook·tsx
import { useEffect, useState } from "react";

export function useDebouncedValue<T>(value: T, delayMs: number): T {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(id);
  }, [value, delayMs]);
  return debounced;
}

// Usage — search input that only fires after the user stops typing.
function Search({ query }: { query: string }) {
  const debouncedQuery = useDebouncedValue(query, 300);
  useEffect(() => {
    if (debouncedQuery) fetch(`/api/search?q=${debouncedQuery}`);
  }, [debouncedQuery]);
  return null;
}

External links

Exercise

Build useMediaQuery(query: string): boolean that returns whether a CSS media query matches and updates as the viewport changes. Use window.matchMedia + an event listener. Then use it in two components — one to render mobile vs desktop layouts, another to show a 'small screen' warning. Confirm the duplication is gone and both components read clean.
Hint
const mql = window.matchMedia(query); setMatches(mql.matches); initially, then mql.addEventListener('change', handler) for live updates. Cleanup removes the listener.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue
💛 by Ttoriwarm

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.