4: React Advanced Concepts

Five concepts that fill gaps from the intermediate material. Each section shows the production-grade version alongside the naive version’s failure modes.


1. useEffect Cleanup — The Full Contract

When a useEffect sets up something ongoing, React requires a cleanup function. The surface-level reason is “prevent memory leaks.” The deeper reason is that React needs cleanup to be correct for its execution model.

React 18 Strict Mode double-invocation. In development, React 18 intentionally mounts every component twice — mount, unmount, mount — to surface bugs in effects that don’t clean up properly. If your effect runs twice and breaks, it means cleanup is incomplete. This is by design: React’s upcoming concurrent features can pause, discard, and replay renders, so effects must be idempotent under repeated execution.

// If your interval fires twice in dev, your cleanup is broken
useEffect(() => {
  const id = setInterval(() => console.log('tick'), 1000)
  return () => clearInterval(id)  // cleanup makes this safe to double-invoke
}, [])

Timing. The cleanup function runs synchronously before the next effect execution and on unmount. It does not run after paint. This matters for subscriptions that must be torn down before the next subscription is set up.

Typing interval and timeout IDs. setInterval returns number in the browser but NodeJS.Timeout in Node. Rather than picking the wrong one, use ReturnType:

import { useState, useEffect } from 'react'

function LiveClock() {
  const [date, setDate] = useState<Date>(() => new Date())
  // Lazy initializer: the function runs once. Without it, `new Date()` runs on every render
  // during server-side rendering, potentially causing hydration mismatches.

  useEffect(() => {
    const id: ReturnType<typeof setInterval> = setInterval(
      () => setDate(new Date()),
      1000
    )
    return () => clearInterval(id)
  }, [])

  return (
    <time dateTime={date.toISOString()} aria-live="off">
      {date.toLocaleTimeString()}
    </time>
  )
}

<time dateTime={...}> is the correct semantic element for a machine-readable timestamp. aria-live="off" prevents the clock from announcing every second to screen readers — the time updates are visual only.

The useState lazy initializer. useState(new Date()) calls new Date() on every render but only uses the result on the first render — the subsequent calls are wasted. The lazy initializer form (useState(() => new Date())) only calls the function once. For expensive initial values (parsing, large arrays), always use the lazy form.


2. useRef — Three Use Cases

useRef gives you a mutable .current that persists across renders without triggering re-renders. Most explanations stop at two use cases. There are three:

Use case 1: DOM references. Access a DOM node directly — focus, measure, scroll:

const inputRef = useRef<HTMLInputElement>(null)

// Safe access with optional chaining — ref.current is null before mount
inputRef.current?.focus()
inputRef.current?.select()

const { width } = inputRef.current?.getBoundingClientRect() ?? { width: 0 }

Use case 2: Mutable bookkeeping. Store values that need to persist across renders but don’t affect the rendered output — interval IDs, request controllers, incrementing counters:

const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
intervalRef.current = setInterval(tick, 1000)
clearInterval(intervalRef.current ?? undefined)

Use case 3: Capturing previous values. This pattern appears constantly in animations, diffs, and undo stacks:

function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T>()
  useEffect(() => {
    ref.current = value
  })
  // Returns the value from the previous render
  // The effect runs after render, so ref.current still holds the old value during render
  return ref.current
}

function Counter() {
  const [count, setCount] = useState(0)
  const prevCount = usePrevious(count)

  return <p>{prevCount} → {count}</p>
}

The stopwatch — correctly typed and structured:

import { useState, useRef } from 'react'

type RunState = 'idle' | 'running' | 'paused'

function Stopwatch() {
  const [seconds, setSeconds] = useState(0)
  const [runState, setRunState] = useState<RunState>('idle')
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
  const inputRef = useRef<HTMLInputElement>(null)

  const start = () => {
    if (intervalRef.current) return  // already running
    setRunState('running')
    intervalRef.current = setInterval(() => setSeconds(s => s + 1), 1000)
  }

  const stop = () => {
    if (intervalRef.current) {
      clearInterval(intervalRef.current)
      intervalRef.current = null
    }
    setRunState('paused')
  }

  const reset = () => {
    stop()
    setSeconds(0)
    setRunState('idle')
  }

  return (
    <div>
      <p role="timer" aria-label={`${seconds} seconds elapsed`}>{seconds}s</p>
      <button onClick={start}  disabled={runState === 'running'}>Start</button>
      <button onClick={stop}   disabled={runState !== 'running'}>Stop</button>
      <button onClick={reset}>Reset</button>
      <hr />
      <input ref={inputRef} placeholder="Type here..." aria-label="Focus target" />
      <button onClick={() => inputRef.current?.focus()}>Focus Input</button>
    </div>
  )
}

Notice the stopwatch manages the interval directly in event handlers rather than through a useEffect reacting to state. The useEffect pattern (effect fires when running state changes) creates an indirect control flow — the button click sets state, which triggers a re-render, which runs the effect, which starts the interval. Direct imperative control in the handler is simpler and easier to reason about for UI interactions.


3. TypeScript Narrowing — Runtime Type Safety

TypeScript checks types at compile time, but unknown inputs (API responses, user-generated data, JSON.parse output) arrive at runtime. TypeScript’s narrowing system lets you restore type safety after working with unknown.

unknown vs any. any disables type checking entirely — you can do anything with it. unknown is the type-safe alternative: you can’t do anything with an unknown value until you narrow it. Always prefer unknown over any for data you don’t control:

// Bad — 'any' spreads through the codebase, silencing all type errors
function processInput(input: any) {
  input.foo.bar()  // TypeScript doesn't check this at all
}

// Good — 'unknown' forces you to narrow before use
function processInput(input: unknown) {
  if (typeof input === 'string') {
    input.toUpperCase()  // safe — TypeScript knows it's a string here
  }
}

The narrowing toolkit:

// Primitives
typeof x === 'string'    // 'string' | 'number' | 'boolean' | 'undefined' | 'bigint' | 'symbol' | 'function'
typeof x === 'object' && x !== null  // object (remember: typeof null === 'object')

// Arrays — typeof returns 'object', so you need a dedicated check
Array.isArray(x)

// Class instances
x instanceof Date
x instanceof Error

// Property existence — narrows to types that have the property
'message' in x  // only true if x is an object with a 'message' key

// User-defined type guards — returns boolean but tells TypeScript what it means
function isUser(x: unknown): x is User {
  return (
    typeof x === 'object' &&
    x !== null &&
    'id' in x &&
    'name' in x &&
    typeof (x as any).id === 'number' &&
    typeof (x as any).name === 'string'
  )
}

Type guards in practice — validating API responses:

interface ApiPost {
  id: number
  title: string
  body: string
}

function isApiPost(x: unknown): x is ApiPost {
  return (
    typeof x === 'object' &&
    x !== null &&
    typeof (x as Record<string, unknown>).id === 'number' &&
    typeof (x as Record<string, unknown>).title === 'string'
  )
}

async function fetchPost(id: number): Promise<ApiPost> {
  const res = await fetch(`/api/posts/${id}`)
  const data: unknown = await res.json()
  if (!isApiPost(data)) throw new Error('Unexpected API shape')
  return data  // TypeScript now knows this is ApiPost
}

DynamicDisplay — using narrowing to branch on prop types:

function DynamicDisplay({ input }: { input?: unknown }) {
  if (input === undefined || input === null || input === false || input === '') {
    return <LiveClock />
  }

  if (Array.isArray(input)) {
    return (
      <>
        {input.map((item, i) => (
          // Using index keys is acceptable here only because the array is read-only
          // and will never be reordered or have items removed
          <div key={i}>{String(item)}</div>
        ))}
      </>
    )
  }

  return <div>{String(input)}</div>
}

Note: the original !input check would render <LiveClock /> for input={0} — falsy but a valid number. Being explicit about which falsy values you intend to handle is safer.


4. Editable Cells — Toggle Between Display and Edit Mode

A value that displays as text but switches to an input on click. The naive implementation misses two things: auto-focus when entering edit mode (the primary UX expectation), and syncing the draft when the value prop changes externally.

Auto-focus with useRef. When you switch to edit mode, the input should receive focus immediately. The timing problem: the input doesn’t exist in the DOM yet at the moment you call setIsEditing(true). You need to focus after React renders the input. useEffect with isEditing as a dependency handles this:

import { useState, useRef, useEffect } from 'react'

interface EditableTextProps {
  value: string
  onSave: (val: string) => void
}

function EditableText({ value, onSave }: EditableTextProps) {
  const [isEditing, setIsEditing] = useState(false)
  const [draft, setDraft] = useState(value)
  const inputRef = useRef<HTMLInputElement>(null)

  // Sync draft when parent updates the value (e.g., undo, remote update)
  useEffect(() => {
    if (!isEditing) setDraft(value)
  }, [value, isEditing])

  // Auto-focus after React renders the input
  useEffect(() => {
    if (isEditing) inputRef.current?.focus()
  }, [isEditing])

  const commit = () => {
    onSave(draft.trim() || value)  // don't save empty string
    setIsEditing(false)
  }

  const cancel = () => {
    setDraft(value)
    setIsEditing(false)
  }

  if (!isEditing) {
    return (
      <span
        role="button"
        tabIndex={0}
        onClick={() => setIsEditing(true)}
        onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') setIsEditing(true) }}
        aria-label={`Edit: ${value}`}
        style={{ cursor: 'text' }}
      >
        {value}
      </span>
    )
  }

  return (
    <input
      ref={inputRef}
      value={draft}
      onChange={e => setDraft(e.target.value)}
      onKeyDown={e => {
        if (e.key === 'Enter') commit()
        if (e.key === 'Escape') cancel()
      }}
      onBlur={commit}
      aria-label="Edit value"
    />
  )
}

The display span gets role="button" and tabIndex={0} with a keyDown handler. A <span onClick> is mouse-only — keyboard users can’t activate it without ARIA role and keyboard event handling. The Enter or Space key activating the edit mode mirrors native button behavior.

The onSave timing with onBlur. When a user presses Enter, commit() is called, which calls onSave and sets isEditing to false. Then onBlur fires immediately after — and calls commit() again. To prevent double-saves, you can track whether commit has already run, or use onMouseDown + preventDefault on a submit button instead of onBlur. For simple cases, calling onSave twice with the same value is usually harmless.

function EditableList() {
  const [items, setItems] = useState(['Buy groceries', 'Walk the dog', 'Write code'])

  const handleSave = (index: number, newValue: string) => {
    setItems(prev => prev.map((item, i) => (i === index ? newValue : item)))
  }

  return (
    <ul>
      {items.map((item, i) => (
        <li key={item}>  {/* key on content, not index — content is unique here */}
          <EditableText value={item} onSave={val => handleSave(i, val)} />
        </li>
      ))}
    </ul>
  )
}

5. Derived Values and useMemo

The rule from article 3 — don’t store derived values in state — has a performance companion: useMemo for expensive derivations.

When not to useMemo. useMemo has overhead: it allocates a dependency comparison on every render, and the memoized value is heap-allocated. For cheap operations (arithmetic, small .filter() calls), useMemo adds more cost than it saves. Only memoize when:

  1. The computation is measurably slow (profile first)
  2. The output is passed to a React.memo-wrapped child and you need referential stability
  3. The output is a dependency of another useMemo or useEffect
import { useState, useMemo } from 'react'

// Simple case — no useMemo needed, arithmetic is instant
function MiniCalc() {
  const [a, setA] = useState(0)
  const [b, setB] = useState(0)

  const c = a + b      // derived — computed on every render, fast enough
  const d = c * 2      // derived from derived — still fast

  return (
    <div>
      <label>
        A: <input type="number" value={a} onChange={e => setA(Number(e.target.value))} />
      </label>
      <label>
        B: <input type="number" value={b} onChange={e => setB(Number(e.target.value))} />
      </label>
      <p>C (A + B) = {c}</p>
      <p>D (C × 2) = {d}</p>
    </div>
  )
}

The spreadsheet case — where useMemo earns its cost:

interface CellMap {
  [id: string]: string  // raw value: '10' or '=SUM(A1:A2)'
}

function useResolvedCells(cells: CellMap) {
  return useMemo(() => {
    // Expensive: topological sort + formula evaluation for potentially hundreds of cells
    return resolveCells(cells)
  }, [cells])
  // Only recomputes when the cells object reference changes
  // Combine with useCallback/immer for stable references on partial updates
}

Referential stability for memoized children. The second reason to use useMemo is when you’re passing a derived object or array to a React.memo-wrapped component. Without memoization, a new array reference is created on every render, and React.memo’s shallow equality check sees a “new” prop even if the contents are identical:

// Without memo — ExpensiveList re-renders on every parent render
const filtered = items.filter(item => item.active)
<ExpensiveList items={filtered} />

// With memo — stable reference, ExpensiveList skips re-render when items haven't changed
const filtered = useMemo(
  () => items.filter(item => item.active),
  [items]
)
<ExpensiveList items={filtered} />

React.memo wraps a component and prevents re-renders when props are shallowly equal. useMemo keeps object/array references stable across renders. useCallback does the same for functions. The three work together: React.memo on the child, useMemo/useCallback on the props passed to it.