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) |
// ❌
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
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
collapsedwithout listing it is a stale-closure bug. If re-subscribing is expensive, read the latest value from a ref updated in an effect, or useuseEffectEvent(React 19.2). - Never put
ref.currentin 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
amplitudechanged, though it read the value from a ref. - One
keydownlistener ondocumentfor 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.
"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
matchMedialisteners, notresize+innerWidthin state. One resize tick fired 4 setStates in a real app. - Don't attach both a
ResizeObserverand a windowresizelistener to the samefit(). 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 thecraftskill).
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 |
"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
"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
awaitinsidestartTransitionneeds its ownstartTransitionwrapper. - Coalesce document-sized work triggered by typing (diffs, search indexes, socket emits) into a ~300 ms idle window.
React.memoonly 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 touchwindow. Useimport "server-only"in files with secrets or database access. - Declare every import in
package.json. No transitive-dependency imports (xtermused while@xterm/xtermwas declared). - Scope Motion
layoutIds withuseId(), or two instances of the same component fight over the id. - Normalize API shapes at the boundary: a Go
nilslice arrives asnulland crashes.map. - Redirects happen on the server (
redirect(),next.configredirects), never withrouter.pushin a client page. - Internal links use
next/link. A barecauses a full page reload. - Toasts are for mutations only, not for navigation or selection. No
window.prompt/confirm.
7. Global keyboard shortcuts
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/localStorageat module scope or in render - No
.on(/addEventListener/setInterval/setTimeoutoutside effects or handlers - Every effect that subscribes returns a cleanup
- No
ref.currentin dependency arrays - No component or
m.createdefined inside a component - No
setStateinscroll/pointermove/rAF handlers - No
'use client'inpage.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