DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on • Originally published at stacknotice.com

7 React Anti-Patterns Killing Your Performance (2026)

Every React codebase accumulates bad habits. Patterns that seemed fine in a tutorial turn into real performance problems in production. Most developers know about memoization — but they apply it wrong, or not at all, or in the wrong places.

This guide covers 7 anti-patterns that show up consistently in real codebases, with the exact code change that fixes each one. No abstract advice — just before and after.

The Cost of Getting This Wrong

A 100ms delay in a UI interaction costs around 1% of engagement. At 300ms, users notice. These aren't theoretical problems: bad React patterns cause jank on scroll, sluggish forms, and unnecessary network waterfalls. The browser has limited time to render each frame. Your React tree is not free.


Anti-Pattern 1: Creating Objects and Functions Inside Render

The most common mistake that silently kills memoization.

The problem:

// ❌ Every render creates a new object reference
function UserCard({ userId }: { userId: string }) {
  const style = { color: 'blue', fontWeight: 'bold' }

  return <ExpensiveChild config={{ userId, theme: 'dark' }} style={style} />
}
Enter fullscreen mode Exit fullscreen mode

Every time UserCard renders, style and config are new object references. Even if the values are identical, === returns false. If ExpensiveChild is wrapped in React.memo, it still re-renders — because the props reference changed.

The fix:

// ✅ Move constants outside the component
const STYLE = { color: 'blue', fontWeight: 'bold' }

function UserCard({ userId }: { userId: string }) {
  const config = useMemo(() => ({ userId, theme: 'dark' }), [userId])

  return <ExpensiveChild config={config} style={STYLE} />
}
Enter fullscreen mode Exit fullscreen mode

Rules:

  • Constants that never change → move outside the component entirely
  • Objects derived from props → useMemo with the right dependencies
  • Functions passed as callbacks → useCallback

The same applies to inline arrow functions in JSX:

// ❌ New function reference on every render
<Button onClick={() => handleClick(item.id)} />

// ✅ Stable reference
const handleItemClick = useCallback(() => handleClick(item.id), [item.id, handleClick])
<Button onClick={handleItemClick} />
Enter fullscreen mode Exit fullscreen mode

Anti-Pattern 2: Using Index as Key in Lists

This one causes subtle, maddening bugs — not just performance issues.

The problem:

// ❌ Index-as-key breaks state and animations on reorder/delete
{items.map((item, index) => (
  <TodoItem key={index} item={item} />
))}
Enter fullscreen mode Exit fullscreen mode

React uses keys to match elements between renders. When you use index, deleting the first item makes React think the second item became the first, the third became the second, etc. Any component state (checked boxes, input values, focus state) shifts to the wrong item. Animations glitch. Performance suffers because React reconciles the wrong elements.

The fix:

// ✅ Use a stable, unique ID from your data
{items.map((item) => (
  <TodoItem key={item.id} item={item} />
))}
Enter fullscreen mode Exit fullscreen mode

If your data doesn't have IDs, generate them when creating the items — not during render:

import { nanoid } from 'nanoid'

function addItem(text: string) {
  return {
    id: nanoid(), // Generated once, stable forever
    text,
    completed: false,
  }
}
Enter fullscreen mode Exit fullscreen mode

Index as key is only acceptable for truly static lists that never change order, add items, or remove items. In practice, this is rare.


Anti-Pattern 3: useEffect for Everything

The most overused hook in React. Most useEffect calls in real codebases should be something else.

Problem 1: Derived state

// ❌ Syncing state with useEffect
function OrderSummary({ items }: { items: CartItem[] }) {
  const [total, setTotal] = useState(0)

  useEffect(() => {
    setTotal(items.reduce((sum, item) => sum + item.price * item.qty, 0))
  }, [items])

  return <div>Total: ${total}</div>
}
Enter fullscreen mode Exit fullscreen mode

This causes two renders per items change. It's also just more code.

// ✅ Derived value — calculate during render
function OrderSummary({ items }: { items: CartItem[] }) {
  const total = items.reduce((sum, item) => sum + item.price * item.qty, 0)

  return <div>Total: ${total}</div>
}
Enter fullscreen mode Exit fullscreen mode

Problem 2: Event-triggered work

// ❌ useEffect triggered by a flag
function PaymentForm() {
  const [submitted, setSubmitted] = useState(false)

  useEffect(() => {
    if (submitted) {
      processPayment()
      setSubmitted(false)
    }
  }, [submitted])

  return <button onClick={() => setSubmitted(true)}>Pay</button>
}
Enter fullscreen mode Exit fullscreen mode
// ✅ Just call it in the event handler
function PaymentForm() {
  const handleSubmit = async () => {
    await processPayment()
  }

  return <button onClick={handleSubmit}>Pay</button>
}
Enter fullscreen mode Exit fullscreen mode

The React team's rule: if you're setting state from an effect, question whether the effect is necessary. Most of the time, it isn't.

Problem 3: Data fetching in useEffect

// ❌ Waterfall fetching with useEffect — no caching, no deduplication
function Profile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null)

  useEffect(() => {
    fetchUser(userId).then(setUser)
  }, [userId])

  if (!user) return <Spinner />
  return <div>{user.name}</div>
}
Enter fullscreen mode Exit fullscreen mode
// ✅ TanStack Query — caching, deduplication, background refresh
function Profile({ userId }: { userId: string }) {
  const { data: user, isLoading } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  })

  if (isLoading) return <Spinner />
  return <div>{user.name}</div>
}
Enter fullscreen mode Exit fullscreen mode

Anti-Pattern 4: Missing Suspense Boundaries

Suspense isn't just for lazy loading components. In React 18/19, it's the primitive for async UI.

The problem:

// ❌ No suspense boundary — entire page blocks
function Dashboard() {
  return (
    <div>
      <Header />
      <StatsPanel />     {/* Slow — fetches analytics */}
      <RecentActivity /> {/* Also slow — different fetch */}
      <QuickActions />   {/* Fast — static */}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

The fix:

// ✅ Independent Suspense boundaries — each section loads independently
function Dashboard() {
  return (
    <div>
      <Header />
      <Suspense fallback={<StatsSkeleton />}>
        <StatsPanel />
      </Suspense>
      <Suspense fallback={<ActivitySkeleton />}>
        <RecentActivity />
      </Suspense>
      <QuickActions />
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Now StatsPanel and RecentActivity load in parallel. QuickActions renders immediately.

In Next.js App Router, async Server Components handle this on the server — no client-side waterfall at all:

// app/dashboard/StatsPanel.tsx
async function StatsPanel() {
  const stats = await fetchStats() // Runs on the server
  return <div>{stats.value}</div>
}
Enter fullscreen mode Exit fullscreen mode

Anti-Pattern 5: Context That Re-Renders the Entire Tree

React Context is a sharp tool. Misuse it and your entire app re-renders on every state change.

The problem:

// ❌ One context with everything — any change re-renders all consumers
const AppContext = createContext<AppState | null>(null)

function AppProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null)
  const [theme, setTheme] = useState<'light' | 'dark'>('dark')
  const [cart, setCart] = useState<CartItem[]>([])
  const [notifications, setNotifications] = useState<Notification[]>([])

  return (
    <AppContext.Provider value={{ user, setUser, theme, setTheme, cart, setCart, notifications, setNotifications }}>
      {children}
    </AppContext.Provider>
  )
}
Enter fullscreen mode Exit fullscreen mode

Every time notifications updates, every component that consumes AppContext re-renders — including the Header that only cares about user.

The fix — split by update frequency:

// ✅ Separate contexts for separate concerns
const UserContext = createContext<UserContextType | null>(null)
const ThemeContext = createContext<ThemeContextType | null>(null)
const CartContext = createContext<CartContextType | null>(null)
const NotificationContext = createContext<NotificationContextType | null>(null)
Enter fullscreen mode Exit fullscreen mode

Or split into read and write contexts — the pattern Daishi Kato (creator of Jotai, Zustand) recommends:

// ✅ Read context (stable object) + Write context (stable dispatch)
const CounterValueContext = createContext(0)
const CounterDispatchContext = createContext<Dispatch<Action> | null>(null)

function CounterProvider({ children }: { children: ReactNode }) {
  const [count, dispatch] = useReducer(reducer, 0)

  return (
    <CounterDispatchContext.Provider value={dispatch}>
      <CounterValueContext.Provider value={count}>
        {children}
      </CounterValueContext.Provider>
    </CounterDispatchContext.Provider>
  )
}
Enter fullscreen mode Exit fullscreen mode

Now components that only dispatch actions won't re-render when the count changes, because dispatch is stable.


Anti-Pattern 6: useState for Non-Reactive Values

Not every value needs to trigger a re-render.

The problem:

// ❌ Storing a timer ref in state — causes re-render when it changes
function AutoSave({ value }: { value: string }) {
  const [timer, setTimer] = useState<ReturnType<typeof setTimeout> | null>(null)

  const scheduleAutoSave = () => {
    if (timer) clearTimeout(timer)
    const newTimer = setTimeout(() => saveValue(value), 1000)
    setTimer(newTimer) // This triggers a re-render!
  }

  return <input value={value} onChange={scheduleAutoSave} />
}
Enter fullscreen mode Exit fullscreen mode
// ✅ useRef — mutable, no re-render
function AutoSave({ value }: { value: string }) {
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)

  const scheduleAutoSave = () => {
    if (timerRef.current) clearTimeout(timerRef.current)
    timerRef.current = setTimeout(() => saveValue(value), 1000)
  }

  return <input value={value} onChange={scheduleAutoSave} />
}
Enter fullscreen mode Exit fullscreen mode

Use useRef for: DOM refs, timer IDs, previous values, any mutable value the component reads but doesn't need to react to.


Anti-Pattern 7: Optimizing Without Measuring

The meta anti-pattern: wrapping everything in useMemo and useCallback "just in case."

The problem:

// ❌ Premature memoization everywhere
function Component({ items, user, config }: Props) {
  const filteredItems = useMemo(() => items.filter(i => i.active), [items])
  const userName = useMemo(() => user.firstName + ' ' + user.lastName, [user])
  const isAdmin = useMemo(() => user.role === 'admin', [user])
  const handleClick = useCallback(() => doSomething(), [])
  const handleChange = useCallback((e) => setValue(e.target.value), [])
}
Enter fullscreen mode Exit fullscreen mode

useMemo and useCallback have a cost: the closure, the dependency comparison, the cache. For cheap operations, this cost exceeds the benefit.

What to actually do:

  1. Measure first. Use React DevTools Profiler. A component that renders in 0.5ms doesn't need optimization.
  2. Memoize at component boundaries. React.memo on expensive components > useMemo inside a cheap one.
  3. Rule for useMemo: use it when the computation takes >1ms or creates a new reference that would break a downstream React.memo.
// ✅ Measure first, then optimize specifically
const expensiveData = useMemo(() => {
  return processLargeDataset(rawData) // verified 10ms in Profiler
}, [rawData])
Enter fullscreen mode Exit fullscreen mode

If you're on React 19 with the React Compiler, you can remove most manual memoization — the compiler handles it better than we do.


Quick Reference

Situation Use
Value computed from props/state Derived state or useMemo
DOM reference useRef
Timer ID / subscription useRef
Value that triggers re-render useState
Stable callback for memoized child useCallback
Expensive computation useMemo
Complex shared state Zustand / Jotai

The Bigger Pattern

Most of these anti-patterns share a root cause: using more React machinery than needed.

  • Derived state doesn't need useState + useEffect
  • Non-reactive values don't need useState — they need useRef
  • Context doesn't need splitting if nothing is expensive
  • useMemo doesn't help if the child isn't React.memo'd

The goal isn't to use all the hooks — it's to use the minimum needed to make your component work correctly. Start simple. Measure. Optimize where the data tells you to.


Full guide with more detail at stacknotice.com/blog/react-anti-patterns-performance-2026

Top comments (0)