---
title: "React pitfalls that cost speed or correctness (speed)"
description: "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."
canonical: https://void-design.vercel.app/docs/speed/react-pitfalls
lastModified: 2026-09-16
---

# React pitfalls that cost speed or correctness

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 `<head>` 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 |
| `setInterval` → `setState` → 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 `<Activity mode="hidden">` (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 `layoutId`s 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 `<a href="/x">` 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

- https://react.dev/reference/react/useSyncExternalStore
- https://react.dev/reference/react/useTransition · https://react.dev/reference/react/useDeferredValue
- https://react.dev/reference/react/useEffectEvent · https://react.dev/reference/react/Activity
- https://motion.dev/docs/react-use-motion-value-event
- https://nextjs.org/docs/app/getting-started/server-and-client-components
