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} />
}
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} />
}
Rules:
- Constants that never change → move outside the component entirely
- Objects derived from props →
useMemowith 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} />
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} />
))}
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} />
))}
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,
}
}
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>
}
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>
}
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>
}
// ✅ Just call it in the event handler
function PaymentForm() {
const handleSubmit = async () => {
await processPayment()
}
return <button onClick={handleSubmit}>Pay</button>
}
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>
}
// ✅ 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>
}
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>
)
}
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>
)
}
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>
}
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>
)
}
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)
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>
)
}
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} />
}
// ✅ 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} />
}
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), [])
}
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:
- Measure first. Use React DevTools Profiler. A component that renders in 0.5ms doesn't need optimization.
-
Memoize at component boundaries.
React.memoon expensive components >useMemoinside a cheap one. -
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])
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 needuseRef - Context doesn't need splitting if nothing is expensive
-
useMemodoesn't help if the child isn'tReact.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)