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

Custom Hooks + forwardRef Patterns

~14 min · react, hooks, patterns

Level 0Curious
0 XP0/93 lessons0/18 achievements
0/100 XP to next level100 XP to go0% complete

Custom hooks aren't 'advanced' — they're how you organize

The moment you find yourself using two related hooks together in more than one place, extract them. That's the whole rule. The convention is to prefix the function with use so React's lint rules know to treat it as a hook (so the rules of hooks apply: don't call it conditionally, don't call it inside loops).

My chat view runs on a useChat() hook. My voice playback runs on useVoice(). Destructive buttons use useArmedAction() — a two-click arm-and-fire that replaces window.confirm(). And every slow operation shows a ticking timer from useElapsedSeconds(). None of them are 'advanced' — they're just the natural extraction once a pattern repeats.

forwardRef — when a parent needs the child's DOM node

Refs don't pass through props normally. If you want a parent to control the focus of a child input, you need forwardRef. React 19 simplifies this: function components can accept ref as a regular prop now, no forwardRef wrapper needed in most cases.

useImperativeHandle — expose a custom ref API

Sometimes you don't want the parent to have the raw DOM node — you want to expose specific methods (focus(), scrollToBottom()) without the DOM contract leaking. useImperativeHandle lets the child decide what the ref looks like.

When to use it: Almost never, until you've shipped enough React to feel the pain it solves. If you're reaching for it as your first tool, you're probably building too much imperative state into the view layer.

Code

useElapsedSeconds — a real hook from cwkPippa's frontend·tsx
/** A slow operation shows a ticking elapsed timer, never a static
 *  "Loading…" — otherwise "working" and "wedged" look the same.
 *  Tagged by the start time so a stale value cannot bleed across runs. */
export function useElapsedSeconds(active: boolean): number {
  const [state, setState] = useState<{ start: number | null; value: number }>({
    start: null,
    value: 0,
  });

  useEffect(() => {
    if (!active) {
      setState({ start: null, value: 0 });
      return;
    }
    const start = Date.now();
    setState({ start, value: 0 });
    const id = setInterval(() => {
      setState({ start, value: Math.floor((Date.now() - start) / 1000) });
    }, 1000);
    return () => clearInterval(id);
  }, [active]);

  return state.start === null ? 0 : state.value;
}
InputArea with forwardRef — parent can focus the textarea·tsx
interface InputAreaHandle { focus: () => void }

const InputArea = forwardRef<InputAreaHandle, Props>((props, ref) => {
  const textareaRef = useRef<HTMLTextAreaElement>(null);
  useImperativeHandle(ref, () => ({
    focus: () => textareaRef.current?.focus(),
  }), []);
  return <textarea ref={textareaRef} {...props} />;
});

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.