React state management simplified – choose the right state strategy in 5 minutes

Introduction

This article will explore the different ways to maintain “state”—the memory of your React application.

Choosing the right state management approach can be the difference between a clean, scalable app and a “prop-drilling” nightmare. This guide will compare the four most common approaches: useState, useReducer, Context API, and External Stores.


Managing State – Do’s and Do Not’s

When architecting your React app, follow these best practices to keep your data flow predictable.

  • DO keep state as local as possible. If only one component needs it, use useState.
  • DO use useReducer when one state change affects multiple sub-values (like a form) or when the state value is a result of complex logic or calculations.
  • DON’T put everything in a global store; it makes debugging harder and can slow down performance.
  • DO lift state up to the nearest common ancestor when two sibling components need the same data.
  • DO return a new object in your Reducer rather than mutating the old one.
MethodBest For…ComplexityScaling
useStateLocal UI toggle, simple inputsLowLimited
useReducerComplex logic, related state piecesMediumMedium
Context APITheming, User Auth, LanguageMediumMedium
ExternalComplex data, high-frequency updatesHighExcellent

Defining the Scenarios

Our comparison focuses on four specific use cases. This post highlights the implementation of the first three, with a deep dive into external stores coming in a later post.

  1. Local State: Managing a simple toggle or text input.
  2. Complex Local State: Managing a character’s stats (Health, Mana, XP) in a game.
  3. Shared State: Passing a “Dark Mode” setting across the entire app.
  4. Complex Global State: Managing a shopping cart with persistent storage.

Implementation Guide

1. Local State with useState

Most of your state should live here. It’s fast, simple, and built into React.

JavaScript

import React, { useState } from 'react';

function Counter() {
  // Define a state variable called "count"
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

2. The “Mission Control” with useReducer

When your state logic gets hairy—like a game character with multiple stats—useReducer is your best friend. It separates the “what happened” (Action) from the “how to update” (Reducer).

JavaScript

// The Reducer function handles all the logic outside the component, in one place
function reducer(state, action) {
  switch (action.type) {
    case 'attacked': return { ...state, hp: state.hp - 10 };
    case 'heal': return { ...state, hp: state.hp + 10 };
    default: return state;
  }
}

const [state, dispatch] = useReducer(reducer, { hp: 100 });

// Trigger it anywhere
dispatch({ type: 'attacked' });

Pros: Centralized logic, easier to test, great for complex objects.

Cons: More boilerplate code than a simple useState.

3. Global Strategy with Context API

When you need to share data without passing props manually through every level, use Context.

JavaScript

const ThemeContext = React.createContext('light');
// ... wrap your app in <ThemeContext.Provider>
const theme = useContext(ThemeContext);

Pros: Included in React, avoids prop-drilling.

Cons: Can cause unnecessary re-renders if not managed carefully.


Which one should you use?

Open your code editor and look at your component tree.

  • If the data is a single value: useState.
  • If the data is an object with related properties: useReducer.
  • If the data is “Global” (User info, Theme): Context API.
  • If the data is massive and changes constantly: Zustand or Redux.

The result of picking the right tool might not look “exciting” in the code itself, but your future self—who has to debug this in six months—will definitely give you a pat on the back!

Alternative state management frameworks such as Redux will be addressed in a future post. Keep your eyes peeled!