Common React 18 Mistakes and How to Avoid Them

6 min read

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.memo where 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

Leave a Reply

Your email address will not be published. Required fields are marked *