AL.
🇪🇸 ES
Back to blog
JavaScript & React · 11 min read

React useState Anti-Patterns Every Reviewer Should Flag

From duplicated state to broken hooks — how to spot and fix the most common useState mistakes in React pull requests.


You’re reviewing a React pull request and see useState everywhere. Some states are computed from props, others are syncing with each other, and there’s a useEffect trying to keep everything in sync. Your spider-sense tingles—something’s wrong, but what?

React’s useState is deceptively simple. It’s so easy to use that developers often reach for it when they shouldn’t. This leads to bugs, unnecessary re-renders, and maintenance nightmares.

In this article, we’ll explore seven common useState anti-patterns that every code reviewer should flag, with examples of what’s wrong and how to fix it.

Anti-Pattern 1: Duplicated/Derived State

The Bad

function ProductCard({ product }) {
  const [price, setPrice] = useState(product.price);
  const [tax, setTax] = useState(product.price * 0.1);
  const [total, setTotal] = useState(product.price * 1.1);

  // Trying to keep derived state in sync
  useEffect(() => {
    setTax(price * 0.1);
    setTotal(price * 1.1);
  }, [price]);

  return (
    <div>
      <p>Price: ${price}</p>
      <p>Tax: ${tax}</p>
      <p>Total: ${total}</p>
    </div>
  );
}

Why It’s Bad

  1. State is duplicated unnecessarily
  2. useEffect creates a second render cycle
  3. If product.price prop changes, state is stale
  4. Three state variables when you only need one (or zero!)

The Good

function ProductCard({ product }) {
  // Just compute derived values directly
  const tax = product.price * 0.1;
  const total = product.price * 1.1;

  return (
    <div>
      <p>Price: ${product.price}</p>
      <p>Tax: ${tax.toFixed(2)}</p>
      <p>Total: ${total.toFixed(2)}</p>
    </div>
  );
}

If computation is expensive, use useMemo:

function ProductList({ products }) {
  // Only recalculate when products array changes
  const totalValue = useMemo(() => {
    return products.reduce((sum, p) => sum + p.price * p.quantity, 0);
  }, [products]);

  return <div>Total: ${totalValue}</div>;
}

Rule of Thumb

Don’t store anything in state that can be computed from existing state or props.

Anti-Pattern 2: Mutating State Directly

The Bad

function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: 'Learn React', done: false }
  ]);

  function toggleTodo(id) {
    // Bad: Mutating state directly
    const todo = todos.find(t => t.id === id);
    todo.done = !todo.done;
    setTodos(todos); // React won't detect the change!
  }

  function addTodo(text) {
    // Bad: Mutating array directly
    todos.push({ id: Date.now(), text, done: false });
    setTodos(todos); // React won't re-render!
  }

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id} onClick={() => toggleTodo(todo.id)}>
          {todo.done ? '✅' : '⬜'} {todo.text}
        </li>
      ))}
    </ul>
  );
}

Why It’s Bad

React compares state by reference. If you mutate the object/array and pass the same reference, React thinks nothing changed and won’t re-render.

The Good

function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: 'Learn React', done: false }
  ]);

  function toggleTodo(id) {
    // Good: Create new array with updated object
    setTodos(todos.map(todo =>
      todo.id === id
        ? { ...todo, done: !todo.done }
        : todo
    ));
  }

  function addTodo(text) {
    // Good: Create new array with new item
    setTodos([...todos, { id: Date.now(), text, done: false }]);
  }

  function deleteTodo(id) {
    // Good: Create new array without item
    setTodos(todos.filter(todo => todo.id !== id));
  }

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          <span onClick={() => toggleTodo(todo.id)}>
            {todo.done ? '✅' : '⬜'} {todo.text}
          </span>
          <button onClick={() => deleteTodo(todo.id)}>Delete</button>
        </li>
      ))}
      <button onClick={() => addTodo('New task')}>Add Todo</button>
    </ul>
  );
}

For Nested Objects

function UserProfile() {
  const [user, setUser] = useState({
    name: 'John',
    address: {
      city: 'New York',
      zip: '10001'
    }
  });

  function updateCity(newCity) {
    // Bad: Mutates nested object
    // user.address.city = newCity;
    // setUser(user);

    // Good: Create new objects all the way down
    setUser({
      ...user,
      address: {
        ...user.address,
        city: newCity
      }
    });
  }

  return <div>{user.address.city}</div>;
}

For deeply nested state, consider using Immer or useReducer.

Anti-Pattern 3: State That Should Be a Ref

The Bad

function VideoPlayer({ src }) {
  const [videoRef, setVideoRef] = useState(null);
  const [playCount, setPlayCount] = useState(0);

  function handlePlay() {
    // Bad: This triggers a re-render!
    setPlayCount(playCount + 1);
    console.log('Played', playCount, 'times');
  }

  return (
    <video
      ref={setVideoRef}
      src={src}
      onPlay={handlePlay}
    />
  );
}

Why It’s Bad

playCount is incremented just for tracking, but changing it triggers a re-render. If the value isn’t displayed in the UI, it shouldn’t be in state.

The Good

function VideoPlayer({ src }) {
  const videoRef = useRef(null);
  const playCountRef = useRef(0);

  function handlePlay() {
    // Good: Updates without re-rendering
    playCountRef.current += 1;
    console.log('Played', playCountRef.current, 'times');
  }

  return (
    <video
      ref={videoRef}
      src={src}
      onPlay={handlePlay}
    />
  );
}

When to Use Refs vs State

Use useState when:

  • The value is displayed in the UI
  • Changes should trigger re-renders

Use useRef when:

  • Storing DOM references
  • Tracking values that don’t affect rendering (timers, previous values, counters)
  • Storing mutable values that persist across renders
function Timer() {
  const [seconds, setSeconds] = useState(0);
  const intervalRef = useRef(null);

  function start() {
    // Store interval ID in ref (doesn't need to trigger render)
    intervalRef.current = setInterval(() => {
      setSeconds(s => s + 1); // This DOES trigger render
    }, 1000);
  }

  function stop() {
    clearInterval(intervalRef.current);
  }

  return (
    <div>
      <p>Time: {seconds}s</p>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
    </div>
  );
}

The Bad

function UserRegistration() {
  const [username, setUsername] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
  const [success, setSuccess] = useState(false);

  async function handleSubmit(e) {
    e.preventDefault();
    setIsLoading(true);
    setError(null);
    setSuccess(false);

    try {
      await registerUser({ username, email, password });
      setSuccess(true);
      setUsername('');
      setEmail('');
      setPassword('');
    } catch (err) {
      setError(err.message);
    } finally {
      setIsLoading(false);
    }
  }

  // ... render form
}

Why It’s Bad

  • Six related state variables that change together
  • Easy to forget to update one
  • Hard to reason about state transitions
  • Multiple setState calls cause multiple re-renders

The Good

const initialState = {
  username: '',
  email: '',
  password: '',
  isLoading: false,
  error: null,
  success: false
};

function registrationReducer(state, action) {
  switch (action.type) {
    case 'SET_FIELD':
      return { ...state, [action.field]: action.value };

    case 'SUBMIT_START':
      return { ...state, isLoading: true, error: null, success: false };

    case 'SUBMIT_SUCCESS':
      return {
        ...initialState, // Reset form
        success: true
      };

    case 'SUBMIT_ERROR':
      return { ...state, isLoading: false, error: action.error };

    default:
      return state;
  }
}

function UserRegistration() {
  const [state, dispatch] = useReducer(registrationReducer, initialState);

  async function handleSubmit(e) {
    e.preventDefault();
    dispatch({ type: 'SUBMIT_START' });

    try {
      await registerUser({
        username: state.username,
        email: state.email,
        password: state.password
      });
      dispatch({ type: 'SUBMIT_SUCCESS' });
    } catch (err) {
      dispatch({ type: 'SUBMIT_ERROR', error: err.message });
    }
  }

  function handleFieldChange(field, value) {
    dispatch({ type: 'SET_FIELD', field, value });
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={state.username}
        onChange={e => handleFieldChange('username', e.target.value)}
      />
      {state.error && <p className="error">{state.error}</p>}
      {state.success && <p className="success">Registration successful!</p>}
      <button disabled={state.isLoading}>
        {state.isLoading ? 'Submitting...' : 'Submit'}
      </button>
    </form>
  );
}

Benefits of useReducer

  • All related state in one object
  • State transitions are explicit and testable
  • Reducer is pure function (easy to test)
  • Single dispatch for complex state updates

Anti-Pattern 5: Stale Closures

The Bad

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    // Bad: Creates stale closure
    setTimeout(() => {
      setCount(count + 1); // Uses count value from when timeout was created
    }, 3000);
  }

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Increment after 3s</button>
    </div>
  );
}

Test it:

  1. Click button 3 times quickly
  2. Wait 3 seconds
  3. Count is 1, not 3!

Why It’s Bad

Each setTimeout captures the count value when the function was created. All three closures have count = 0, so they all set it to 1.

The Good: Functional Updates

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    // Good: Use functional update
    setTimeout(() => {
      setCount(prevCount => prevCount + 1); // Uses latest value
    }, 3000);
  }

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Increment after 3s</button>
    </div>
  );
}

Now clicking 3 times → count becomes 3 ✅

Another Example: Event Handlers in Effects

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  useEffect(() => {
    function handleMessage(msg) {
      // Bad: Stale closure over message
      console.log('Current message:', message);
      setMessage(msg);
    }

    socket.on('message', handleMessage);

    return () => socket.off('message', handleMessage);
  }, [roomId]); // message not in dependencies!

  return <input value={message} onChange={e => setMessage(e.target.value)} />;
}

handleMessage always logs the initial empty string because it’s in a stale closure.

Fix with ref:

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');
  const messageRef = useRef(message);

  useEffect(() => {
    messageRef.current = message; // Keep ref up to date
  });

  useEffect(() => {
    function handleMessage(msg) {
      console.log('Current message:', messageRef.current); // Uses latest
      setMessage(msg);
    }

    socket.on('message', handleMessage);
    return () => socket.off('message', handleMessage);
  }, [roomId]);

  return <input value={message} onChange={e => setMessage(e.target.value)} />;
}

Anti-Pattern 6: Expensive Initialization Without Lazy Initializer

The Bad

function ExpensiveComponent() {
  // Bad: Runs on every render!
  const [data, setData] = useState(computeExpensiveValue());

  return <div>{data}</div>;
}

function computeExpensiveValue() {
  console.log('Computing...');
  let result = 0;
  for (let i = 0; i < 1000000000; i++) {
    result += i;
  }
  return result;
}

Even though data only uses the initial value, computeExpensiveValue() runs on every render.

The Good: Lazy Initialization

function ExpensiveComponent() {
  // Good: Function only runs once
  const [data, setData] = useState(() => computeExpensiveValue());

  return <div>{data}</div>;
}

By passing a function, React only calls it on the initial render.

Another Example: Reading from localStorage

// Bad: Reads localStorage on every render
function UserPreferences() {
  const [theme, setTheme] = useState(localStorage.getItem('theme') || 'light');

  return <div className={theme}>...</div>;
}

// Good: Reads localStorage only once
function UserPreferences() {
  const [theme, setTheme] = useState(() => {
    return localStorage.getItem('theme') || 'light';
  });

  useEffect(() => {
    localStorage.setItem('theme', theme);
  }, [theme]);

  return <div className={theme}>...</div>;
}

Anti-Pattern 7: Boolean State Explosion

The Bad

function DataFetcher() {
  const [isLoading, setIsLoading] = useState(false);
  const [isError, setIsError] = useState(false);
  const [isEmpty, setIsEmpty] = useState(false);
  const [isSuccess, setIsSuccess] = useState(false);

  async function fetchData() {
    setIsLoading(true);
    setIsError(false);
    setIsSuccess(false);
    setIsEmpty(false);

    try {
      const data = await fetch('/api/data').then(r => r.json());

      if (data.length === 0) {
        setIsEmpty(true);
      } else {
        setIsSuccess(true);
      }
    } catch (error) {
      setIsError(true);
    } finally {
      setIsLoading(false);
    }
  }

  if (isLoading) return <Spinner />;
  if (isError) return <Error />;
  if (isEmpty) return <Empty />;
  if (isSuccess) return <Data />;
}

Why It’s Bad

  • Four boolean flags for mutually exclusive states
  • Easy to get into invalid state (e.g., both isError and isSuccess true)
  • Forgot to reset a flag? Bugs.

The Good: State Machine

function DataFetcher() {
  const [state, setState] = useState({ status: 'idle', data: null, error: null });

  async function fetchData() {
    setState({ status: 'loading', data: null, error: null });

    try {
      const data = await fetch('/api/data').then(r => r.json());

      if (data.length === 0) {
        setState({ status: 'empty', data: [], error: null });
      } else {
        setState({ status: 'success', data, error: null });
      }
    } catch (error) {
      setState({ status: 'error', data: null, error });
    }
  }

  switch (state.status) {
    case 'idle':
      return <button onClick={fetchData}>Load Data</button>;
    case 'loading':
      return <Spinner />;
    case 'error':
      return <Error message={state.error.message} />;
    case 'empty':
      return <Empty />;
    case 'success':
      return <Data items={state.data} />;
    default:
      return null;
  }
}

Now it’s impossible to be in two states at once.

Using a Library

For complex state machines, use XState or similar:

import { useMachine } from '@xstate/react';
import { createMachine } from 'xstate';

const fetchMachine = createMachine({
  id: 'fetch',
  initial: 'idle',
  states: {
    idle: { on: { FETCH: 'loading' } },
    loading: {
      on: {
        SUCCESS: 'success',
        ERROR: 'error',
        EMPTY: 'empty'
      }
    },
    success: { on: { FETCH: 'loading' } },
    error: { on: { FETCH: 'loading' } },
    empty: { on: { FETCH: 'loading' } }
  }
});

function DataFetcher() {
  const [state, send] = useMachine(fetchMachine);

  async function fetchData() {
    send('FETCH');
    try {
      const data = await fetch('/api/data').then(r => r.json());
      send(data.length === 0 ? 'EMPTY' : 'SUCCESS');
    } catch {
      send('ERROR');
    }
  }

  // Render based on state.matches()
}

Summary: Quick Reference for Code Reviews

When reviewing React code, flag these patterns:

❌ Bad✅ Good
Storing computed values in stateCompute during render or use useMemo
Mutating state directlyCreate new objects/arrays
Using state for non-render valuesUse useRef
Many related state variablesUse useReducer
setState(value) in async codeUse setState(prev => ...)
Expensive initializationUse useState(() => ...)
Multiple boolean flagsUse state machines

Conclusion

useState is powerful but easy to misuse. These anti-patterns lead to bugs, performance issues, and confusing code.

As a reviewer:

  • Look for derived state that can be computed
  • Check that state updates are immutable
  • Question whether state is needed vs refs
  • Suggest useReducer for complex related state
  • Watch for stale closures in effects and timeouts
  • Ensure lazy initialization for expensive operations
  • Push back on boolean state explosion

Your future self (and your teammates) will thank you for catching these early.