ReactCommon Mistakes & Anti-Patterns

Common Mistakes & Anti-Patterns

React has a handful of traps that catch nearly every developer at least once. The bugs they produce range from subtly wrong UIs to infinite loops that crash the browser tab. This page documents the 10 most common mistakes, why they happen, and how to fix each one permanently.

Mistake 1: Mutating State Directly

React's re-render system detects changes by comparing the previous and next state reference. Mutating the existing object or array means the reference does not change, so React sees no difference and skips the re-render.

TSX
// ✗ Direct mutation — React won't re-render
const [items, setItems] = useState(['a', 'b', 'c'])

function addItem() {
  items.push('d')        // mutates the existing array
  setItems(items)        // same reference — React bails out
}

function removeFirst() {
  items.splice(0, 1)     // mutates in place
  setItems(items)        // still the same reference
}

// ✓ Fix — always create a new array/object
function addItem() {
  setItems(prev => [...prev, 'd'])           // spread creates new array
}

function removeFirst() {
  setItems(prev => prev.slice(1))            // slice returns new array
}

// ✓ For objects — spread to copy, then override the changed key
const [user, setUser] = useState({ name: 'Alice', age: 30 })

function birthday() {
  setUser(prev => ({ ...prev, age: prev.age + 1 }))  // new object
}
Mistake 2: Missing useEffect Cleanup

Effects that subscribe to events, start timers, or initiate requests must return a cleanup function. Without it you create memory leaks, duplicate event listeners, and state updates on unmounted components.

TSX
// ✗ No cleanup — event listener piles up every render
useEffect(() => {
  window.addEventListener('resize', handleResize)
}, [])  // handler is never removed

// ✗ No cleanup — setState called after component unmounts
useEffect(() => {
  fetch('/api/data').then(r => r.json()).then(setData)
}, [])

// ✓ Fix — return cleanup function
useEffect(() => {
  window.addEventListener('resize', handleResize)
  return () => window.removeEventListener('resize', handleResize)
}, [handleResize])

// ✓ Fix — AbortController cancels the request on cleanup
useEffect(() => {
  const controller = new AbortController()

  fetch('/api/data', { signal: controller.signal })
    .then(r => r.json())
    .then(setData)
    .catch(err => {
      if (err.name !== 'AbortError') setError(err.message)
    })

  return () => controller.abort()
}, [])
Mistake 3: Using Array Index as Key

React's reconciler uses keys to identify which elements have moved, been added, or been removed. When the index is the key, deleting or reordering items confuses React — it matches old state to the wrong new element, producing incorrect inputs, broken animations, and stale UI.

TSX
// ✗ Index as key — if item[0] is deleted, all keys shift, causing chaos
{tasks.map((task, i) => <Task key={i} data={task} />)}

// ✓ Fix — use a stable, unique identifier from your data
{tasks.map(task => <Task key={task.id} data={task} />)}

// If your data has no IDs, generate them at creation time:
const newTask = { id: crypto.randomUUID(), text: input }
Mistake 4: Infinite Render Loop

Calling setState inside useEffect without a dependency array (or with the wrong deps) creates an infinite loop: the effect fires, sets state, React re-renders, the effect fires again, and so on.

TSX
// ✗ Infinite loop — no dependency array means every render triggers the effect
useEffect(() => {
  setCount(c => c + 1)   // triggers re-render → effect runs again → ...
})

// ✗ Also infinite — count is in deps but the effect updates count
useEffect(() => {
  setCount(count + 1)
}, [count])

// ✓ Fix — empty array means run once, no dependency on count
useEffect(() => {
  setCount(1)  // initialize once
}, [])

// ✓ Fix — functional update does not require count in deps
useEffect(() => {
  const id = setInterval(() => setCount(c => c + 1), 1000)
  return () => clearInterval(id)
}, [])  // no deps needed — functional updater reads current value safely
Mistake 5: Stale Closures in useEffect

A closure in JavaScript captures variables at the time it is created. If useEffect captures a variable and that variable later changes, the effect still sees the old value — a stale closure.

TSX
// ✗ Stale closure — count is captured as 0 and never updates
const [count, setCount] = useState(0)

useEffect(() => {
  const id = setInterval(() => {
    console.log(count)       // always logs 0!
    setCount(count + 1)      // always sets 0 + 1 = 1
  }, 1000)
  return () => clearInterval(id)
}, [])  // empty deps — count is stale

// ✓ Fix 1 — functional update reads the latest value at call time
useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1)   // no closure issue — c is current
  }, 1000)
  return () => clearInterval(id)
}, [])

// ✓ Fix 2 — add count to deps (effect re-creates interval when count changes)
useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1)
  }, 1000)
  return () => clearInterval(id)
}, [count])
Note
The `exhaustive-deps` ESLint rule catches most stale closure bugs automatically. Always address its warnings — never silence them with a disable comment.
Mistake 6: Over-Lifting State

Moving state to a parent component makes it accessible to siblings, but it also means every state change re-renders the parent and all its children. If only one child needs the state, keep it there.

TSX
// ✗ Over-lifted — App re-renders every time the search input changes
function App() {
  const [searchQuery, setSearchQuery] = useState('')  // only SearchBar uses this
  return (
    <>
      <Header />       {/* re-renders on every keystroke */}
      <SearchBar query={searchQuery} onChange={setSearchQuery} />
      <Sidebar />      {/* re-renders on every keystroke */}
    </>
  )
}

// ✓ Colocated — only SearchBar re-renders
function SearchBar() {
  const [query, setQuery] = useState('')
  return <input value={query} onChange={e => setQuery(e.currentTarget.value)} />
}
Mistake 7: Prop Drilling 5+ Levels Deep

Passing data through 5 or more layers of intermediary components that do not use the data is a maintenance nightmare. Any rename or signature change ripples through every layer. Use React Context or a state manager:

TSX
// ✗ Drilling — every layer passes user down even though it doesn't use it
<App user={user}>
  <Layout user={user}>
    <Sidebar user={user}>
      <NavMenu user={user}>
        <UserAvatar user={user} />  {/* only this needs it */}
      </NavMenu>
    </Sidebar>
  </Layout>
</App>

// ✓ Fix — Context makes user available anywhere without passing it down
const UserContext = createContext<User | null>(null)

<UserContext.Provider value={user}>
  <Layout>
    <Sidebar>
      <NavMenu>
        <UserAvatar />  {/* reads from context directly */}
      </NavMenu>
    </Sidebar>
  </Layout>
</UserContext.Provider>
Mistake 8: Using useEffect for Event-Driven Logic

useEffect is for synchronizing with external systems. It is not a general-purpose "run this when X happens" tool. Logic that should run in response to a user action belongs in the event handler, not an effect.

TSX
// ✗ Anti-pattern — effect watching state to trigger an action
function PaymentForm() {
  const [submitted, setSubmitted] = useState(false)

  useEffect(() => {
    if (submitted) {
      sendPayment()  // should run in the submit handler, not an effect
    }
  }, [submitted])

  return <button onClick={() => setSubmitted(true)}>Pay</button>
}

// ✓ Fix — logic directly in the event handler
function PaymentForm() {
  const handleSubmit = async () => {
    await sendPayment()
  }

  return <button onClick={handleSubmit}>Pay</button>
}
Mistake 9: Inline Objects/Arrays in JSX Props

An object or array literal in JSX is a new reference every render. This defeats React.memo, breaks dependency arrays in child hooks, and causes unnecessary network requests if the value is used in a child useEffect dependency array.

TSX
// ✗ New [] every render — React.memo on DataGrid is useless
<DataGrid columns={['id', 'name', 'email']} style={{ padding: 16 }} />

// ✓ Stable references — defined outside component or memoized
const COLUMNS = ['id', 'name', 'email']
const GRID_STYLE = { padding: 16 }

<DataGrid columns={COLUMNS} style={GRID_STYLE} />

// If the value depends on props/state, useMemo it:
const columns = useMemo(() => buildColumns(schema), [schema])
Mistake 10: Global State for Local Concerns

Reaching for Redux, Zustand, or Context for state that is only relevant to one component or one screen adds unnecessary complexity. Not everything needs to be global.

TSX
// ✗ Overkill — modal visibility stored in global Redux store
dispatch(setModalOpen(true))

// ✓ Local state — modal only relevant to this component
const [isModalOpen, setIsModalOpen] = useState(false)
  • Global state is for: auth user, theme, cart, notifications — things truly shared across the app

  • Local state is for: form fields, UI toggles, component-specific loading flags

  • When in doubt, start local and lift up only when a second component needs it

Warning
These mistakes are not obvious to beginners because they often appear to "work" in simple scenarios. They reveal themselves as bugs at scale — with more components, more data, and more concurrent interactions.