Skip to content
C.W.K.
Stream
Lesson 02 of 10 · published

Hooks Mastery — useState, useEffect, useRef

~15 min · react, hooks, state

Level 0Curious
0 XP0/93 lessons0/18 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 optional, but never cosmetic: leave it out and the effect re-runs after every commit; an empty array runs it 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;
    // the newest window only: a long conversation is read a page at a time
    fetch(`/api/conversations/${conversationId}/page?limit=300`)
      .then(r => r.json())
      .then(page => { if (!cancelled) setMessages(page.messages); });
    return () => { cancelled = true; };
  }, [conversationId]);

  const sendMessage = useCallback(async (text: string) => {
    abortRef.current?.abort();
    const controller = new AbortController();
    abortRef.current = controller;
    setIsStreaming(true);
    try {
      await streamChat(conversationId, text, controller.signal, chunk => {
        setMessages(prev => applyChunk(prev, chunk));
      });
    } finally {
      // an older send that was aborted must not clear the flag of the newer one
      if (abortRef.current === controller) setIsStreaming(false);
    }
  }, [conversationId]);

  // Stop asks the server to end the turn; aborting the fetch alone would not
  const stop = () => { interruptTurn(conversationId); abortRef.current?.abort(); };

  return { messages, isStreaming, sendMessage, stop };
}

Progress

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

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.