Skip to content

Reference · speed

React pitfalls that cost speed or correctness

speed/references/react-pitfalls.md229 linesupdated 16 Sept 2026

Every rule below comes from a real bug in shipped apps or vendored registry components (commit hashes in the void research notes). React 19.2, Next 16.

1. Nothing with side effects in the render body

Render runs on the server, and it may run many times on the client. Subscriptions, timers and DOM access in render leak listeners, crash SSR, or cause hydration mismatches.

Bug seen in the wild Fix
scrollY.on("change", …) in the component body (adds a listener on every render) useMotionValueEvent(scrollY, "change", cb) or useEffect with the unsubscribe returned
setInterval(…) in render (stale-closure slideshow; a terminal refocused its input 10×/s and stole clicks) useEffect + clearInterval in cleanup. Blinking carets in CSS
m.create(Component) / styled(...) / const Inner = () => … inside render (remounts and replays the animation every render) Define at module scope, or useMemo(() => m.create(C), [])
useTransform(...) inside a style={{}} literal Hooks at the top level only
createPortal(x, document.body) in render Portal after mount (§3) or use the primitive's Portal
navigator.userAgent at module scope Read it inside an effect or useSyncExternalStore
navigator.platform in render to show ⌘ vs Ctrl (showed ⌘K on Linux) useSyncExternalStore with a server snapshot (§3)
tsx
// ❌
function Header() {
  const { scrollY } = useScroll();
  scrollY.on("change", (y) => setScrolled(y > 8));   // leaks + setState per frame
}
// ✅
"use client";
import { useScroll, useMotionValueEvent } from "motion/react";
function Header() {
  const { scrollY } = useScroll();
  const ref = useRef<HTMLElement>(null);
  useMotionValueEvent(scrollY, "change", (y) => ref.current?.toggleAttribute("data-scrolled", y > 8)); // DOM write, no re-render
  return <header ref={ref} className="data-[scrolled]:shadow-1">…</header>;
}

2. Effects: always clean up

tsx
useEffect(() => {
  const id = setInterval(tick, 1000);
  const onKey = (e: KeyboardEvent) => { /* … */ };
  document.addEventListener("keydown", onKey);
  const io = new IntersectionObserver(cb);
  io.observe(el);
  const ctrl = new AbortController();
  fetch(url, { signal: ctrl.signal }).then(/* … */).catch(() => {});
  let raf = requestAnimationFrame(loop);
  return () => {
    clearInterval(id);
    document.removeEventListener("keydown", onKey);
    io.disconnect();
    ctrl.abort();
    cancelAnimationFrame(raf);
    // library instances: carousel.destroy(), lenis?.destroy(), api.off("select", cb), editor.dispose()
  };
}, [/* every value read inside */]);
  • Exhaustive deps. A handler that reads collapsed without listing it is a stale-closure bug. If re-subscribing is expensive, read the latest value from a ref updated in an effect, or use useEffectEvent (React 19.2).
  • Never put ref.current in a dependency array. It isn't reactive and hides bugs.
  • Ref-backed props never go in effect deps. A GL backdrop destroyed and rebuilt its context whenever amplitude changed, though it read the value from a ref.
  • One keydown listener on document for all shortcuts, not one per row (20 listeners were found in a list).
  • No useEffect(() => fetch…) for page data in App Router. Fetch in the Server Component.

3. Browser-only values: useSyncExternalStore

Gives the server and hydration a stable snapshot, then the real value, with no mismatch warning and no extra useEffect + useState render.

tsx
"use client";
import { useSyncExternalStore } from "react";

/** Reactive media query. Server snapshot = fallback. */
export function useMediaQuery(query: string, serverFallback = false) {
  return useSyncExternalStore(
    (onChange) => {
      const mql = matchMedia(query);
      mql.addEventListener("change", onChange);
      return () => mql.removeEventListener("change", onChange);
    },
    () => matchMedia(query).matches,
    () => serverFallback,
  );
}

export const usePrefersReducedMotion = () => useMediaQuery("(prefers-reduced-motion: reduce)", false);
export const useCanHover = () => useMediaQuery("(hover: hover) and (pointer: fine)", false);

/** Platform modifier key label. "Ctrl" on the server, "⌘" after hydration on Apple devices. */
const noopSubscribe = () => () => {};
export function useModKey() {
  return useSyncExternalStore(
    noopSubscribe,
    () => (/Mac|iPhone|iPad/.test(navigator.userAgent) ? "⌘" : "Ctrl"),
    () => "Ctrl",
  );
}

/** true only after hydration (for portals and client-only widgets) */
export function useMounted() {
  return useSyncExternalStore(noopSubscribe, () => true, () => false);
}
  • Use matchMedia listeners, not resize + innerWidth in state. One resize tick fired 4 setStates in a real app.
  • Don't attach both a ResizeObserver and a window resize listener to the same fit(). That's double layout.
  • Layout that depends on viewport size belongs in CSS (container queries, clamp()), not JS.
  • Theme (light/dark) is set by a blocking inline script in and read from CSS, not from React state (see the craft skill).

4. Per-frame values never go through React state

Bug Cost Fix
Spotlight effect: setState({x, y}) on every mousemove Full re-render ~60–120×/s Write a CSS variable on the element: el.style.setProperty("--x", x + "px")
Scramble text: setState per letter per rAF Main-thread churn Mutate textContent in rAF, fixed-size box, sr-only real text
setIntervalsetState → spring on height Layout every frame Transform-only animation, or CSS
NumberTicker creating Intl.NumberFormat every frame GC pressure Hoist the formatter; skip no-op writes
tsx
"use client";
import { useRef } from "react";
export function Spotlight({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  return (
    <div
      ref={ref}
      onPointerMove={(e) => {
        const r = e.currentTarget.getBoundingClientRect();
        e.currentTarget.style.setProperty("--x", `${e.clientX - r.left}px`);
        e.currentTarget.style.setProperty("--y", `${e.clientY - r.top}px`);
      }}
      className="relative [background:radial-gradient(400px_circle_at_var(--x)_var(--y),var(--brand-subtle),transparent_60%)]"
    >
      {children}
    </div>
  );
}

Set the variable on the element itself, not on a parent with many children. Updating a variable on a parent recalculates styles for the whole subtree. During drags, set transform directly.

5. Keep interactions under 200 ms

tsx
"use client";
import { useState, useTransition, useDeferredValue } from "react";
import { yieldToMain } from "@/lib/yield";

export function Filters({ items }: { items: Item[] }) {
  const [tab, setTab] = useState("all");
  const [isPending, startTransition] = useTransition();
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query);          // typing stays instant; the list catches up

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} aria-label="Filter" />
      <button
        type="button"
        aria-pressed={tab === "active"}
        onClick={() => startTransition(() => setTab("active"))}   // non-urgent: interruptible
      >Active</button>
      <List items={items} tab={tab} query={deferredQuery} dim={isPending} />
    </>
  );
}

async function onSave(e: React.MouseEvent<HTMLButtonElement>) {
  e.currentTarget.dataset.state = "saving";   // 1. visual acknowledgement now
  await yieldToMain();                        // 2. let the browser paint
  track("save");                              // 3. analytics and heavy work after the paint
}
  • State set after an await inside startTransition needs its own startTransition wrapper.
  • Coalesce document-sized work triggered by typing (diffs, search indexes, socket emits) into a ~300 ms idle window.
  • React.memo only helps with stable props. editor={{…}} object literals or unmemoized callbacks from custom hooks defeat it. Or enable the React Compiler.
  • No write-only state (state that is set but never rendered). It's a re-render for nothing. Use a ref.
  • Use (React 19.2) for tab panels that should keep their state without re-mounting.

6. Server/client module hygiene

  • Importing a Server Component into a 'use client' file bundles it for the client. Compose from a server parent instead.
  • Guard browser-only modules: import "client-only" in files that touch window. Use import "server-only" in files with secrets or database access.
  • Declare every import in package.json. No transitive-dependency imports (xterm used while @xterm/xterm was declared).
  • Scope Motion layoutIds with useId(), or two instances of the same component fight over the id.
  • Normalize API shapes at the boundary: a Go nil slice arrives as null and crashes .map.
  • Redirects happen on the server (redirect(), next.config redirects), never with router.push in a client page.
  • Internal links use next/link. A bare causes a full page reload.
  • Toasts are for mutations only, not for navigation or selection. No window.prompt/confirm.

7. Global keyboard shortcuts

ts
export function isTypingTarget(t: EventTarget | null) {
  const el = t as HTMLElement | null;
  return !!el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || !!el.closest("[role=textbox], .monaco-editor, .xterm"));
}
useEffect(() => {
  const onKey = (e: KeyboardEvent) => {
    if (e.defaultPrevented || isTypingTarget(e.target)) return;
    if (e.key === "k" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); openPalette(); }
  };
  document.addEventListener("keydown", onKey);
  return () => document.removeEventListener("keydown", onKey);
}, [openPalette]);

One binding per chord across the app (Ctrl+K once opened two dialogs).

8. Lint checklist (what void lint and review should catch)

  • No window/document/navigator/localStorage at module scope or in render
  • No .on(/addEventListener/setInterval/setTimeout outside effects or handlers
  • Every effect that subscribes returns a cleanup
  • No ref.current in dependency arrays
  • No component or m.create defined inside a component
  • No setState in scroll/pointermove/rAF handlers
  • No 'use client' in page.tsx/layout.tsx
  • rAF loops check visibility and are cancelled in cleanup

Sources