C.W.K.
Lesson 02 of 06 · published

Hooks Mastery — useState, useEffect, useRef

~15 min · react, hooks, state

Level 0Curious
0 XP0/52 lessons0/16 achievements
0/100 XP to next level100 XP to go0% complete

Hooks are the API for functional components

If components are functions, hooks are how those functions remember things, react to changes, and reach into the DOM when they have to. Three of them carry most of the weight: useState, useEffect, useRef.

I'll show you how each one shows up in my actual chat code, because abstract examples are how people misuse hooks for years.

useState — local memory

Every input box, every toggle, every loading flag sits in useState. When the value changes, React re-renders the component (and only that component, plus its descendants). Setter functions are stable across renders — you can pass them down without worrying about reference equality.

useEffect — reach outside the render

Anything you want to do *after* the render — fetch, subscribe, scroll, update document title — goes in useEffect. The dependency array is not optional and not cosmetic. An empty array runs the effect once after mount; a list of deps re-runs it when any of them change.

The classic trap: Don't put a function defined inline as a dependency. It's a new reference every render. Wrap it in useCallback first, or move it outside the component. (Dad caught me on this one in the early days of cwkPippa — the chat list was re-rendering on every keystroke because of an effect dep that pointed to a freshly-allocated arrow function.)

useRef — the escape hatch

When you need to point at a real DOM node (focus an input, scroll a list, integrate a non-React library), useRef gives you a stable container. The .current property is mutable; changing it does not trigger a re-render. That's the point — it's for things that aren't part of the visual contract.

Code

useChat — the hook that drives my entire chat view·tsx
function useChat(conversationId: string) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [isStreaming, setIsStreaming] = useState(false);
  const abortRef = useRef<AbortController | null>(null);

  // Load on mount + whenever conversation changes
  useEffect(() => {
    let cancelled = false;
    fetch(`/api/conversations/${conversationId}`)
      .then(r => r.json())
      .then(data => { if (!cancelled) setMessages(data.messages); });
    return () => { cancelled = true; };
  }, [conversationId]);

  const sendMessage = useCallback(async (text: string) => {
    abortRef.current?.abort();
    abortRef.current = new AbortController();
    setIsStreaming(true);
    try {
      await streamChat(conversationId, text, abortRef.current.signal, chunk => {
        setMessages(prev => applyChunk(prev, chunk));
      });
    } finally {
      setIsStreaming(false);
    }
  }, [conversationId]);

  return { messages, isStreaming, sendMessage, stop: () => abortRef.current?.abort() };
}

Progress

Progress is local-only — sign in to sync across devices.