Skip to content

React — Pitfalls

OWNER: floris, floor ALSO_USED_BY: alexander, tobias LAST_VERIFIED: 2026-03-26 GE_STACK_VERSION: React 19.x


Overview

Known failure modes in React development. Every entry is a real mistake that has cost debugging time. Format: ANTI_PATTERN with FIX — agents match against these during code review.


1. Stale Closures

1.1 Stale State in Callbacks

ANTI_PATTERN: reading state directly inside a callback registered once

// BUG: count is always 0 in the interval
useEffect(() => {
  const id = setInterval(() => {
    console.log(count); // stale — captures initial value
    setCount(count + 1); // always sets to 1
  }, 1000);
  return () => clearInterval(id);
}, []); // empty deps = closure never refreshes

FIX: use functional state update

useEffect(() => {
  const id = setInterval(() => {
    setCount((prev) => prev + 1); // always reads latest
  }, 1000);
  return () => clearInterval(id);
}, []);

1.2 Stale Props in Effects

ANTI_PATTERN: using a prop in an effect without it in the dependency array

useEffect(() => {
  fetchData(userId); // stale if userId changes
}, []); // missing userId dependency

FIX: include all used values in the dependency array

useEffect(() => {
  fetchData(userId);
}, [userId]);

1.3 Stale Closure in Event Handlers

ANTI_PATTERN: event handler captures stale value from render scope

const handleClick = () => {
  setTimeout(() => {
    alert(count); // shows value at click time, not current
  }, 3000);
};

FIX: use a ref for latest value when needed in delayed callbacks

const countRef = useRef(count);
countRef.current = count;

const handleClick = () => {
  setTimeout(() => {
    alert(countRef.current); // always current
  }, 3000);
};


2. Infinite Re-renders

2.1 Object/Array in Dependency Array

ANTI_PATTERN: inline object or array as useEffect dependency

// BUG: new object every render → effect runs every render
useEffect(() => {
  fetchData(filters);
}, [{ status: "active", page: 1 }]); // new ref each render

FIX: memoize or extract to stable reference

const filters = useMemo(() => ({ status: "active", page: 1 }), []);
useEffect(() => {
  fetchData(filters);
}, [filters]);

2.2 State Update Inside Render

ANTI_PATTERN: calling setState during render without condition

function Component({ items }: Props) {
  const [filtered, setFiltered] = useState(items);
  setFiltered(items.filter(i => i.active)); // BUG: infinite loop
  return <List items={filtered} />;
}

FIX: compute derived values during render — no state needed

function Component({ items }: Props) {
  const filtered = items.filter(i => i.active); // derived, no state
  return <List items={filtered} />;
}

2.3 useEffect That Updates Its Own Dependency

ANTI_PATTERN: effect updates state that is in its own dependency array

useEffect(() => {
  setData(transform(data)); // data changes → effect re-runs → infinite
}, [data]);

FIX: compute during render or use functional update

// Option A: derive during render
const transformedData = useMemo(() => transform(data), [data]);

// Option B: if you must use effect, guard against redundant updates
useEffect(() => {
  const result = transform(rawData);
  setData((prev) => {
    if (JSON.stringify(prev) === JSON.stringify(result)) return prev;
    return result;
  });
}, [rawData]);

2.4 Missing Function Stability

ANTI_PATTERN: passing inline function as prop to memoized child

// Parent re-renders → new function ref → MemoChild re-renders
<MemoChild onSelect={(id) => handleSelect(id)} />

FIX: stabilize with useCallback

const onSelect = useCallback((id: string) => handleSelect(id), [handleSelect]);
<MemoChild onSelect={onSelect} />


3. useEffect Traps

3.1 Data Fetching in useEffect

ANTI_PATTERN: fetching data with useEffect + useState

const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
  fetch("/api/data")
    .then((res) => res.json())
    .then(setData)
    .catch(setError)
    .finally(() => setLoading(false));
}, []);

FIX: use TanStack Query — handles caching, race conditions, dedup, refetch

const { data, isLoading, error } = useQuery({
  queryKey: ["data"],
  queryFn: () => fetch("/api/data").then((res) => res.json()),
});

3.2 Missing Cleanup

ANTI_PATTERN: subscribing in useEffect without cleanup

useEffect(() => {
  window.addEventListener("resize", handleResize);
  // BUG: never unsubscribed → memory leak
}, []);

FIX: always return a cleanup function

useEffect(() => {
  window.addEventListener("resize", handleResize);
  return () => window.removeEventListener("resize", handleResize);
}, []);

3.3 Race Condition on Fetch

ANTI_PATTERN: no abort on rapid dependency changes

useEffect(() => {
  fetch(`/api/user/${userId}`)
    .then((res) => res.json())
    .then(setUser); // BUG: stale response overwrites fresh one
}, [userId]);

FIX: use AbortController (or TanStack Query which handles this)

useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/user/${userId}`, { signal: controller.signal })
    .then((res) => res.json())
    .then(setUser)
    .catch((err) => {
      if (err.name !== "AbortError") throw err;
    });
  return () => controller.abort();
}, [userId]);

3.4 Syncing Props to State

ANTI_PATTERN: useEffect to copy props into state

const [localValue, setLocalValue] = useState(prop);
useEffect(() => {
  setLocalValue(prop); // unnecessary sync — extra render
}, [prop]);

FIX: use the prop directly, or use key to reset

// Option A: use prop directly (derived state)
const displayValue = format(prop);

// Option B: reset component with key when prop changes
<Editor key={documentId} initialContent={content} />


4. Memo Misuse

4.1 Memoizing Cheap Operations

ANTI_PATTERN: useMemo for trivial computations

const count = useMemo(() => items.length, [items]); // pointless
const label = useMemo(() => `${name} (${role})`, [name, role]); // pointless

FIX: compute directly — memo overhead exceeds the computation cost

const count = items.length;
const label = `${name} (${role})`;

4.2 React.memo Without Stable Props

ANTI_PATTERN: memoizing a component but passing unstable props

const MemoList = React.memo(ExpensiveList);

// Parent still creates new object every render
<MemoList config={{ sort: "asc", limit: 10 }} />

FIX: stabilize props with useMemo or extract to constant

const config = useMemo(() => ({ sort: "asc", limit: 10 }), []);
<MemoList config={config} />

4.3 Memo With React Compiler

ANTI_PATTERN: adding manual useMemo/useCallback when React Compiler is active FIX: React Compiler handles memoization automatically — remove manual memo IF: profiling proves the compiler missed a specific case THEN: add manual memo with a comment explaining why


5. Key Prop Mistakes

5.1 Using Index as Key

ANTI_PATTERN: using array index as key for dynamic lists

{items.map((item, index) => (
  <Item key={index} data={item} /> // BUG: reorder/delete causes wrong state
))}

FIX: use a stable unique identifier

{items.map((item) => (
  <Item key={item.id} data={item} />
))}

5.2 Missing Key on Fragments

ANTI_PATTERN: mapping to fragments without key

{pairs.map((pair) => (
  <> {/* BUG: missing key */}
    <dt>{pair.label}</dt>
    <dd>{pair.value}</dd>
  </>
))}

FIX: use <Fragment> with explicit key

import { Fragment } from "react";
{pairs.map((pair) => (
  <Fragment key={pair.id}>
    <dt>{pair.label}</dt>
    <dd>{pair.value}</dd>
  </Fragment>
))}

5.3 Not Using Key to Reset Component State

ANTI_PATTERN: useEffect to reset local state when an ID changes FIX: change the key prop — React unmounts and remounts fresh

// Instead of useEffect(() => resetForm(), [userId])
<UserForm key={userId} user={user} />


6. Context Re-render Cascades

6.1 Monolithic Context

ANTI_PATTERN: single context with many unrelated fields

const AppContext = createContext({
  theme: "light",
  user: null,
  sidebarOpen: false,
  notifications: [],
  locale: "en",
});
// Every consumer re-renders when ANY field changes

FIX: split into focused contexts

const ThemeContext = createContext<Theme>("light");
const UserContext = createContext<User | null>(null);
const SidebarContext = createContext<SidebarState>(defaultSidebar);

6.2 Unstable Context Value

ANTI_PATTERN: creating new object in provider render

<AuthContext.Provider value={{ user, login, logout }}>
  {/* New object every render → all consumers re-render */}
</AuthContext.Provider>

FIX: memoize the context value

const value = useMemo(() => ({ user, login, logout }), [user, login, logout]);
<AuthContext.Provider value={value}>
  {children}
</AuthContext.Provider>

6.3 Context for Frequently Changing Values

ANTI_PATTERN: context for values that change on every frame (mouse position, scroll) FIX: use a ref + subscription pattern or zustand with selectors


7. Server Component Pitfalls

7.1 Importing Client Code in Server Components

ANTI_PATTERN: importing a client-only library in a Server Component FIX: move the import to a "use client" component

7.2 Passing Non-Serializable Props Across Boundary

ANTI_PATTERN: passing functions, Dates, Maps from Server to Client Component FIX: serialize to plain objects — pass ISO strings, arrays, plain objects

7.3 "use client" Too High in the Tree

ANTI_PATTERN: "use client" on a layout or page — makes entire subtree client FIX: push "use client" to the smallest leaf that needs interactivity


8. Zod v4 Pitfall (GE-Specific)

ANTI_PATTERN: accessing .errors on a ZodError object

const result = schema.safeParse(data);
if (!result.success) {
  console.log(result.error.errors); // UNDEFINED in Zod v4
}

FIX: use .issues in Zod v4

if (!result.success) {
  console.log(result.error.issues); // correct
}


Cross-References

READ_ALSO: wiki/docs/stack/react/index.md READ_ALSO: wiki/docs/stack/react/patterns.md READ_ALSO: wiki/docs/stack/react/state-management.md READ_ALSO: wiki/docs/stack/react/forms.md READ_ALSO: wiki/docs/stack/react/checklist.md