Common React 18 Mistakes and How to Avoid Them
Common React 18 Mistakes and How to Avoid Them
React 18 introduced major rendering improvements, better batching, and concurrent capabilities, but many teams still misuse these features in production. This guide breaks down the most common React 18 mistakes, why they happen, and how to avoid them with clean, scalable patterns.
Hook: Why React 18 Bugs Feel Harder to Debug
In React 18, rendering is more flexible and asynchronous under the hood. That is great for UX, but it also exposes weak assumptions in old component logic, especially around effects, state updates, and rendering side effects.
Key Takeaways
- Use Strict Mode warnings as a signal, not a nuisance.
- Keep rendering pure and side effects isolated.
- Understand automatic batching and concurrent updates.
- Use transitions and memoization deliberately, not blindly.
- Modernize root rendering APIs and data-fetching flows.
1. Using Legacy Root APIs Instead of React 18 Root Rendering
One of the first React 18 mistakes is keeping older rendering code. If your app still relies on the legacy API, you miss out on the new rendering model and future-safe behavior.
Wrong Approach
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));
Correct Approach
import { createRoot } from 'react-dom/client';
import App from './App';
const container = document.getElementById('root');
const root = createRoot(container);
root.render(<App />);
This update is foundational because many new React 18 behaviors assume the modern root API.
2. Misunderstanding Strict Mode Double Invocation
Developers often think React 18 is broken when effects or initialization logic run twice in development. In reality, Strict Mode intentionally replays parts of the lifecycle to expose unsafe side effects.
Common Anti-Pattern
useEffect(() => {
fetch('/api/data')
.then((res) => res.json())
.then((data) => setData(data));
}, []);
If this causes duplicate requests in development, the issue is usually not React itself but effect logic that is not resilient.
Better Pattern
useEffect(() => {
const controller = new AbortController();
async function loadData() {
try {
const res = await fetch('/api/data', { signal: controller.signal });
const json = await res.json();
setData(json);
} catch (error) {
if (error.name !== 'AbortError') {
console.error(error);
}
}
}
loadData();
return () => controller.abort();
}, []);
Strict Mode helps reveal effect cleanup problems early. If you want stronger release practices around frontend delivery, it also pairs well with disciplined automation covered in this CI/CD pipelines guide.
3. Putting Side Effects Inside Render Logic
A classic React 18 mistake is assuming rendering happens once and synchronously. In concurrent rendering, React may start, pause, retry, or discard renders. That means render logic must stay pure.
Bad Example
function Profile({ user }) {
localStorage.setItem('lastUser', user.id);
return <div>{user.name}</div>;
}
Safe Version
function Profile({ user }) {
useEffect(() => {
localStorage.setItem('lastUser', user.id);
}, [user.id]);
return <div>{user.name}</div>;
}
Anything that touches storage, logging, analytics, subscriptions, timers, or network calls belongs in an effect or event handler, not in render.
4. Overusing useEffect for Derived State
Many teams use useEffect to calculate values that could be derived directly during rendering. This adds unnecessary renders and mental overhead.
Derived State Anti-Pattern
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
Preferred Approach
const fullName = firstName + ' ' + lastName;
Use effects for synchronization with external systems, not for simple calculations. This is one of the easiest React 18 performance wins.
Pro Tip
If you are writing useEffect mainly to keep one piece of state in sync with another, stop and ask whether the value can be derived directly in render or memoized with useMemo.
5. Ignoring Automatic Batching Changes
React 18 automatically batches more state updates than previous versions, including updates inside promises, timeouts, and native event handlers. That usually improves performance, but code that relied on immediate intermediate renders may behave differently.
Example
setCount((c) => c + 1);
setFlag((f) => !f);
In React 18, these updates are commonly batched into a single render. That is good, but avoid writing logic that depends on reading updated DOM synchronously after each state call.
When You Need Immediate Flush
import { flushSync } from 'react-dom';
flushSync(() => {
setOpen(true);
});
Use flushSync sparingly. If you overuse it, you undermine the scheduling advantages of React 18.
6. Misusing startTransition for Everything
startTransition is powerful, but it is not a universal optimization button. It should be used for non-urgent updates that can be interrupted, such as expensive list filtering or route-like UI transitions.
Reasonable Use Case
import { startTransition, useState } from 'react';
function Search() {
const [input, setInput] = useState('');
const [query, setQuery] = useState('');
function handleChange(e) {
const value = e.target.value;
setInput(value);
startTransition(() => {
setQuery(value);
});
}
return <input value={input} onChange={handleChange} />;
}
Urgent interactions like typing feedback should stay immediate. Heavy downstream UI updates can be deferred.
7. Forgetting to Clean Up Async Effects and Subscriptions
Concurrent rendering and development replays make cleanup bugs more visible. If you attach subscriptions, timers, or event listeners without proper cleanup, your components can leak memory or trigger stale updates.
Incorrect Subscription Handling
useEffect(() => {
window.addEventListener('resize', handleResize);
}, []);
Correct Cleanup
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
This principle also applies to WebSocket connections, observers, intervals, and third-party integrations.
8. Using Unstable Keys in Lists
This is not new, but in React 18 unstable keys can cause even more confusing UI bugs when combined with frequent rerenders and asynchronous updates.
Bad Key Choice
{items.map((item, index) => (
<Row key={index} item={item} />
))}
Better Key Choice
{items.map((item) => (
<Row key={item.id} item={item} />
))}
Stable keys help React preserve component identity, state, and reconciliation accuracy.
9. Premature Memoization Without Measuring
Another common React 18 mistake is wrapping everything in useMemo and useCallback without evidence. Memoization adds complexity and can even hurt performance if used thoughtlessly.
What to Do Instead
- Profile first with React DevTools Profiler.
- Memoize expensive computations, not trivial expressions.
- Use
React.memowhere prop stability actually matters. - Prefer architectural fixes over blanket memoization.
Performance tuning works best when backed by measurement, not guesswork.
10. Fetching Data in Components Without a Clear Strategy
React 18 does not magically solve data fetching complexity. Teams often mix imperative fetching, duplicated loading states, race conditions, and stale closures across many components.
Safer Patterns
- Centralize server-state management with a dedicated library when appropriate.
- Abort requests during cleanup.
- Separate urgent UI state from async result state.
- Standardize error and loading handling.
If your frontend runs behind layered infrastructure in staging or production, understanding request flow also helps. For background, see this reverse proxy crash course.
11. Comparing React 18 Mistakes at a Glance
| Mistake | Why It Happens | How to Fix It |
|---|---|---|
| Legacy root API | Old app bootstrap code | Use createRoot |
| Effects running twice | Strict Mode development checks | Make effects idempotent and clean up properly |
| Side effects in render | Assuming render is always one-pass | Move side effects to effects or handlers |
| Derived state in effects | Overusing useEffect |
Compute values directly during render |
| Overusing transitions | Misunderstanding scheduling priorities | Use only for non-urgent updates |
| Missing cleanup | Forgetting unmount and replay behavior | Return cleanup functions from effects |
12. Final Thoughts on React 18
The biggest React 18 mistakes usually come from carrying forward assumptions from earlier versions. Rendering is more flexible now, and that means your components must be purer, your effects must be cleaner, and your performance work must be more intentional.
If you adopt modern root APIs, treat Strict Mode as a helpful signal, keep side effects out of render, and use concurrent features carefully, your React codebase will be far more predictable and resilient.
FAQ: React 18
Why does React 18 run useEffect twice in development?
React 18 uses Strict Mode development checks to surface unsafe side effects and missing cleanup logic. This does not happen the same way in production builds.
Should I use startTransition for every state update?
No. Use it only for non-urgent updates that can be deferred, such as expensive filtering or large UI refreshes. Immediate interaction feedback should remain urgent.
Is useEffect the right place for derived state?
Usually no. If a value can be calculated from props or existing state, compute it during render or memoize it if the calculation is expensive.
1 comment