DEV Community

Cover image for React Mastery Series — Part 11: State Management in React — Context API, Redux Toolkit & Zustand
Hamed Farazi
Hamed Farazi

Posted on

React Mastery Series — Part 11: State Management in React — Context API, Redux Toolkit & Zustand

Welcome to Part 11 and the final article of the React Mastery Series!

As React applications grow, managing state becomes one of the most important challenges developers face.

At first, useState is usually enough. But what happens when multiple components need access to the same data? What if you have authentication, shopping carts, themes, notifications, filters, or complex application workflows?

That's where state management comes in.

In this article, we'll explore the most important state management approaches in React:

  • React's built-in state management
  • Lifting state up and prop drilling
  • Context API
  • useReducer with Context
  • Redux Toolkit
  • Zustand
  • Best practices and common mistakes
  • How to choose the right solution

By the end, you'll understand when to use each approach and how to build maintainable state management for real-world applications.


1. Understanding State Management

State represents data that can change over time and affect what your application displays.

For example:

const [count, setCount] = useState(0);
Enter fullscreen mode Exit fullscreen mode

When count changes, React updates the components that depend on it.

However, not all state belongs in the same place.

Local State

Local state is used by a single component or a small group of closely related components.

Examples:

  • Input values
  • Dropdown visibility
  • Modal visibility
  • Selected tabs
  • Temporary form data
const [isOpen, setIsOpen] = useState(false);
Enter fullscreen mode Exit fullscreen mode

Shared or Global Client State

Shared state is needed by multiple components across different parts of the application.

Examples:

  • Current theme
  • Shopping cart
  • Authentication UI state
  • User preferences
  • Global notifications

Server State

Server state represents data that comes from an external source, such as an API.

Examples:

  • User profiles
  • Product lists
  • Orders
  • Comments
  • Dashboard statistics

Server state has additional challenges:

  • Caching
  • Loading and error states
  • Refetching
  • Synchronization
  • Pagination
  • Stale data

Libraries such as TanStack Query and SWR are designed to handle these problems.

An important distinction:

Redux Toolkit, Zustand, and Context API can manage client-side application state. TanStack Query and SWR specialize in managing remote server data.

You don't necessarily need one tool to manage every kind of state.


2. React's Built-in State Management

Before installing an external library, understand the tools React already provides.

The most common options are:

  • useState
  • useReducer
  • useContext

useState

Use useState for simple state.

import { useState } from "react";

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

  return (
    <div>
      <h2>Count: {count}</h2>

      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
    </div>
  );
}

export default Counter;
Enter fullscreen mode Exit fullscreen mode

This is perfect when the state belongs to one component.

But what if several components need the same value?

That's where lifting state up becomes useful.


3. Lifting State Up and Prop Drilling

Imagine two components need access to the same username.

You can move the state to their closest common parent and pass it down through props.

function Parent() {
  const [username, setUsername] = useState("Alex");

  return (
    <>
      <Profile username={username} />
      <Welcome username={username} />
    </>
  );
}

function Profile({ username }: { username: string }) {
  return <h2>Profile: {username}</h2>;
}

function Welcome({ username }: { username: string }) {
  return <h2>Welcome, {username}!</h2>;
}
Enter fullscreen mode Exit fullscreen mode

This technique is called lifting state up.

It works well for small component trees.

What Is Prop Drilling?

Prop drilling happens when you pass data through multiple components that don't actually need it.

function App() {
  const username = "Alex";

  return <Layout username={username} />;
}

function Layout({ username }: { username: string }) {
  return <Sidebar username={username} />;
}

function Sidebar({ username }: { username: string }) {
  return <UserProfile username={username} />;
}

function UserProfile({ username }: { username: string }) {
  return <h2>{username}</h2>;
}
Enter fullscreen mode Exit fullscreen mode

As applications grow, this can become difficult to maintain.

One solution is Context API.


4. Context API

Context API is built into React and allows components to share values without passing props through every intermediate component.

It's useful for:

  • Themes
  • Authentication information
  • Language preferences
  • Application settings
  • Other relatively stable shared values

Step 1: Create a Context

import { createContext } from "react";

type ThemeContextValue = {
  theme: "light" | "dark";
  toggleTheme: () => void;
};

export const ThemeContext =
  createContext<ThemeContextValue | undefined>(undefined);
Enter fullscreen mode Exit fullscreen mode

We use undefined as the default value so we can detect when a component is rendered outside its provider.

Step 2: Create a Provider

import {
  createContext,
  useCallback,
  useMemo,
  useState,
  type ReactNode,
} from "react";

type ThemeContextValue = {
  theme: "light" | "dark";
  toggleTheme: () => void;
};

const ThemeContext =
  createContext<ThemeContextValue | undefined>(undefined);

type ThemeProviderProps = {
  children: ReactNode;
};

export function ThemeProvider({
  children,
}: ThemeProviderProps) {
  const [theme, setTheme] = useState<"light" | "dark">("light");

  const toggleTheme = useCallback(() => {
    setTheme((current) =>
      current === "light" ? "dark" : "light"
    );
  }, []);

  const value = useMemo(
    () => ({ theme, toggleTheme }),
    [theme, toggleTheme]
  );

  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

The useCallback and useMemo calls help keep the provider value stable when its dependencies haven't changed.

They don't prevent consumers from updating when the actual context value changes.

Step 3: Create a Custom Hook

import { useContext } from "react";

export function useTheme() {
  const context = useContext(ThemeContext);

  if (!context) {
    throw new Error(
      "useTheme must be used within ThemeProvider"
    );
  }

  return context;
}
Enter fullscreen mode Exit fullscreen mode

This makes Context easier and safer to use.

Step 4: Use the Context

function ThemeButton() {
  const { theme, toggleTheme } = useTheme();

  return (
    <button onClick={toggleTheme}>
      Current theme: {theme}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Wrap Your Application

import { ThemeProvider } from "./ThemeContext";

function App() {
  return (
    <ThemeProvider>
      <MainLayout />
    </ThemeProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now any component inside ThemeProvider can access the theme.

Context API Limitations

Context is useful, but it isn't automatically a complete state management solution.

One important limitation is that consumers of a context can re-render when its provider value changes.

For larger applications, consider:

  1. Splitting unrelated contexts into separate providers.
  2. Keeping provider values stable when their dependencies haven't changed.
  3. Avoiding putting frequently changing, unrelated data into one large context.
  4. Using a state management library when you need more advanced subscriptions, selectors, or debugging tools.

Rule of thumb: Context is excellent for sharing values. It doesn't automatically provide all the features of a dedicated state management library.


5. useReducer with Context

When state becomes more complex, useReducer can provide a more structured approach than multiple useState calls.

A reducer receives the current state and an action, then returns the next state.

Example: Counter Reducer

import { useReducer } from "react";

type State = {
  count: number;
};

type Action =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "reset" };

const initialState: State = {
  count: 0,
};

function counterReducer(
  state: State,
  action: Action
): State {
  switch (action.type) {
    case "increment":
      return { count: state.count + 1 };

    case "decrement":
      return { count: state.count - 1 };

    case "reset":
      return initialState;

    default:
      return state;
  }
}

function Counter() {
  const [state, dispatch] = useReducer(
    counterReducer,
    initialState
  );

  return (
    <div>
      <h2>{state.count}</h2>

      <button onClick={() => dispatch({ type: "increment" })}>
        +
      </button>

      <button onClick={() => dispatch({ type: "decrement" })}>
        -
      </button>

      <button onClick={() => dispatch({ type: "reset" })}>
        Reset
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Instead of directly describing how to update state in every event handler, you dispatch actions describing what happened.

Combining useReducer with Context

You can expose both the state and dispatch function through Context.

import {
  createContext,
  useContext,
  useReducer,
  type ReactNode,
} from "react";

type State = {
  count: number;
};

type Action =
  | { type: "increment" }
  | { type: "decrement" };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "increment":
      return { count: state.count + 1 };

    case "decrement":
      return { count: state.count - 1 };

    default:
      return state;
  }
}

const CounterStateContext = createContext<State | undefined>(
  undefined
);

const CounterDispatchContext =
  createContext<React.Dispatch<Action> | undefined>(undefined);

export function CounterProvider({
  children,
}: {
  children: ReactNode;
}) {
  const [state, dispatch] = useReducer(reducer, {
    count: 0,
  });

  return (
    <CounterStateContext.Provider value={state}>
      <CounterDispatchContext.Provider value={dispatch}>
        {children}
      </CounterDispatchContext.Provider>
    </CounterStateContext.Provider>
  );
}

export function useCounterState() {
  const context = useContext(CounterStateContext);

  if (!context) {
    throw new Error("useCounterState requires CounterProvider");
  }

  return context;
}

export function useCounterDispatch() {
  const context = useContext(CounterDispatchContext);

  if (!context) {
    throw new Error("useCounterDispatch requires CounterProvider");
  }

  return context;
}
Enter fullscreen mode Exit fullscreen mode

Separating state and dispatch contexts can help components that only need to dispatch actions avoid subscribing to the changing state context.

However, components reading the state context still update when that context changes.

When should you use useReducer with Context?

  • When multiple components share structured state.
  • When updates involve several related actions.
  • When you want predictable update logic.
  • When you don't need the full ecosystem of an external library.

6. Redux Toolkit

Redux is a predictable state management library that centralizes application state in a store.

It is commonly used in large applications where predictable updates, debugging, middleware, and a mature ecosystem are important.

Today, Redux Toolkit (RTK) is the recommended approach for writing Redux logic.

It simplifies Redux and removes much of the repetitive code associated with older Redux patterns.

Install Redux Toolkit

npm install @reduxjs/toolkit react-redux
Enter fullscreen mode Exit fullscreen mode

Redux Toolkit Core Concepts

Concept Purpose
Store Holds the application state
Slice Defines a feature's state and reducers
Action Describes something that happened
Reducer Calculates the next state
Dispatch Sends an action to the store
Selector Reads data from the store

Step 1: Create a Slice

Imagine we're building a shopping cart.

// features/cart/cartSlice.ts

import {
  createSlice,
  type PayloadAction,
} from "@reduxjs/toolkit";

type CartItem = {
  id: number;
  name: string;
  price: number;
  quantity: number;
};

type CartState = {
  items: CartItem[];
};

const initialState: CartState = {
  items: [],
};

const cartSlice = createSlice({
  name: "cart",
  initialState,
  reducers: {
    addItem: (state, action: PayloadAction<CartItem>) => {
      const existing = state.items.find(
        (item) => item.id === action.payload.id
      );

      if (existing) {
        existing.quantity += action.payload.quantity;
      } else {
        state.items.push(action.payload);
      }
    },

    removeItem: (state, action: PayloadAction<number>) => {
      state.items = state.items.filter(
        (item) => item.id !== action.payload
      );
    },

    clearCart: (state) => {
      state.items = [];
    },
  },
});

export const {
  addItem,
  removeItem,
  clearCart,
} = cartSlice.actions;

export default cartSlice.reducer;
Enter fullscreen mode Exit fullscreen mode

Notice that we're using syntax that looks like direct mutation:

existing.quantity += action.payload.quantity;
Enter fullscreen mode Exit fullscreen mode

Why is this safe?

Redux Toolkit uses Immer internally. Immer tracks these changes and produces the next immutable state.

This behavior is safe inside Redux Toolkit's Immer-managed reducers. It does not mean you should mutate Redux state directly elsewhere.

Step 2: Configure the Store

// app/store.ts

import { configureStore } from "@reduxjs/toolkit";
import cartReducer from "../features/cart/cartSlice";

export const store = configureStore({
  reducer: {
    cart: cartReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;

export type AppDispatch = typeof store.dispatch;
Enter fullscreen mode Exit fullscreen mode

The store combines your application's reducers and provides a central place for state.

Step 3: Provide the Store to React

// main.tsx

import React from "react";
import ReactDOM from "react-dom/client";
import { Provider } from "react-redux";
import { store } from "./app/store";
import App from "./App";

ReactDOM.createRoot(
  document.getElementById("root")!
).render(
  <Provider store={store}>
    <App />
  </Provider>
);
Enter fullscreen mode Exit fullscreen mode

Step 4: Create Typed Redux Hooks

When using TypeScript, typed hooks make Redux much easier to work with.

// app/hooks.ts

import {
  useDispatch,
  useSelector,
  type TypedUseSelectorHook,
} from "react-redux";

import type {
  RootState,
  AppDispatch,
} from "./store";

export const useAppDispatch = () =>
  useDispatch<AppDispatch>();

export const useAppSelector: TypedUseSelectorHook<RootState> =
  useSelector;
Enter fullscreen mode Exit fullscreen mode

Now your components can access the store with proper TypeScript support.

Step 5: Use Redux in Components

import {
  addItem,
  removeItem,
} from "./features/cart/cartSlice";

import {
  useAppDispatch,
  useAppSelector,
} from "./app/hooks";

function Cart() {
  const items = useAppSelector(
    (state) => state.cart.items
  );

  const dispatch = useAppDispatch();

  const total = items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );

  return (
    <div>
      <h2>Shopping Cart</h2>

      {items.map((item) => (
        <div key={item.id}>
          <span>
            {item.name} x {item.quantity}
          </span>

          <button
            onClick={() => dispatch(removeItem(item.id))}
          >
            Remove
          </button>
        </div>
      ))}

      <h3>Total: ${total.toFixed(2)}</h3>

      <button
        onClick={() =>
          dispatch(
            addItem({
              id: 1,
              name: "Keyboard",
              price: 80,
              quantity: 1,
            })
          )
        }
      >
        Add Keyboard
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Handling Asynchronous Operations

Real applications often need to fetch data from APIs.

Redux Toolkit provides createAsyncThunk for managing asynchronous workflows.

import { createAsyncThunk } from "@reduxjs/toolkit";

type User = {
  id: number;
  name: string;
};

export const fetchUser = createAsyncThunk(
  "users/fetchUser",
  async (userId: number) => {
    const response = await fetch(
      `/api/users/${userId}`
    );

    if (!response.ok) {
      throw new Error("Failed to fetch user");
    }

    return (await response.json()) as User;
  }
);
Enter fullscreen mode Exit fullscreen mode

You can handle the pending, fulfilled, and rejected states in the slice's extraReducers.

For applications with extensive API requirements, RTK Query is another excellent option. It is included in Redux Toolkit and provides API fetching, caching, and cache invalidation features.

When should you use Redux Toolkit?

  • Large applications
  • Complex shared state
  • Many independent features
  • Advanced debugging requirements
  • Middleware and predictable action flows
  • Teams that benefit from established conventions

7. Zustand

Zustand is a lightweight state management library for React.

It provides a simple API without requiring providers or a large amount of boilerplate.

It's especially useful when you want shared state with a straightforward developer experience.

Install Zustand

npm install zustand
Enter fullscreen mode Exit fullscreen mode

Step 1: Create a Store

Let's create a simple counter store.

// stores/counterStore.ts

import { create } from "zustand";

type CounterState = {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
};

export const useCounterStore = create<CounterState>(
  (set) => ({
    count: 0,

    increment: () =>
      set((state) => ({
        count: state.count + 1,
      })),

    decrement: () =>
      set((state) => ({
        count: state.count - 1,
      })),

    reset: () => set({ count: 0 }),
  })
);
Enter fullscreen mode Exit fullscreen mode

Step 2: Use the Store

import { useCounterStore } from "./stores/counterStore";

function Counter() {
  const count = useCounterStore(
    (state) => state.count
  );

  const increment = useCounterStore(
    (state) => state.increment
  );

  const decrement = useCounterStore(
    (state) => state.decrement
  );

  return (
    <div>
      <h2>{count}</h2>

      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

No provider is required.

Why Use Selectors?

Notice this pattern:

const count = useCounterStore(
  (state) => state.count
);
Enter fullscreen mode Exit fullscreen mode

A selector subscribes the component to the selected part of the store.

This can prevent unnecessary updates when unrelated parts of the store change.

Prefer selecting the specific values a component needs instead of subscribing to the entire store.

If you need to select multiple values as an object, consider Zustand's useShallow or an appropriate equality strategy to avoid unnecessary re-renders caused by newly created object references.

Zustand with TypeScript

The store above already uses a TypeScript interface to define its state and actions.

This makes it easier to maintain as the application grows.

You can also organize stores by domain:

src/
  stores/
    authStore.ts
    cartStore.ts
    themeStore.ts
Enter fullscreen mode Exit fullscreen mode

Persisting State

Zustand provides a persist middleware for saving selected state across page reloads.

import { create } from "zustand";
import { persist } from "zustand/middleware";

type PreferencesState = {
  theme: "light" | "dark";
  setTheme: (theme: "light" | "dark") => void;
};

export const usePreferencesStore =
  create<PreferencesState>()(
    persist(
      (set) => ({
        theme: "light",

        setTheme: (theme) => set({ theme }),
      }),
      {
        name: "user-preferences",
      }
    )
  );
Enter fullscreen mode Exit fullscreen mode

By default, this middleware uses browser storage where available.

Security note: Be careful about what you persist. Don't casually store sensitive information, authentication secrets, or access tokens in localStorage. Choose a storage strategy appropriate to your security requirements.

When Should You Use Zustand?

  • Small and medium-sized applications
  • Shared state with minimal boilerplate
  • Projects that don't need Redux's full ecosystem
  • Developers who prefer a simple store-based API
  • Applications that benefit from selector-based subscriptions

8. Practical Example: Building a Shopping Cart with Zustand

Let's put everything together.

We'll build a shopping cart with the following features:

  • Add products
  • Remove products
  • Update quantities
  • Calculate the total price

Step 1: Define the Types

// types.ts

export type Product = {
  id: number;
  name: string;
  price: number;
};

export type CartItem = {
  product: Product;
  quantity: number;
};
Enter fullscreen mode Exit fullscreen mode

Step 2: Create the Cart Store

// stores/cartStore.ts

import { create } from "zustand";
import type { Product, CartItem } from "../types";

type CartState = {
  items: CartItem[];

  addItem: (product: Product) => void;
  removeItem: (id: number) => void;
  setQuantity: (id: number, quantity: number) => void;
  clearCart: () => void;
};

export const useCartStore = create<CartState>(
  (set) => ({
    items: [],

    addItem: (product) =>
      set((state) => {
        const existing = state.items.find(
          (item) => item.product.id === product.id
        );

        if (existing) {
          return {
            items: state.items.map((item) =>
              item.product.id === product.id
                ? {
                    ...item,
                    quantity: item.quantity + 1,
                  }
                : item
            ),
          };
        }

        return {
          items: [
            ...state.items,
            {
              product,
              quantity: 1,
            },
          ],
        };
      }),

    removeItem: (id) =>
      set((state) => ({
        items: state.items.filter(
          (item) => item.product.id !== id
        ),
      })),

    setQuantity: (id, quantity) =>
      set((state) => ({
        items:
          quantity <= 0
            ? state.items.filter(
                (item) => item.product.id !== id
              )
            : state.items.map((item) =>
                item.product.id === id
                  ? { ...item, quantity }
                  : item
              ),
      })),

    clearCart: () => set({ items: [] }),
  })
);
Enter fullscreen mode Exit fullscreen mode

Step 3: Build the Product List

import type { Product } from "./types";
import { useCartStore } from "./stores/cartStore";

const products: Product[] = [
  { id: 1, name: "Keyboard", price: 80 },
  { id: 2, name: "Mouse", price: 40 },
  { id: 3, name: "Monitor", price: 250 },
];

function ProductList() {
  const addItem = useCartStore(
    (state) => state.addItem
  );

  return (
    <section>
      <h2>Products</h2>

      {products.map((product) => (
        <div key={product.id}>
          <span>
            {product.name} — ${product.price}
          </span>

          <button onClick={() => addItem(product)}>
            Add to Cart
          </button>
        </div>
      ))}
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Build the Cart Component

import { useCartStore } from "./stores/cartStore";

function ShoppingCart() {
  const items = useCartStore(
    (state) => state.items
  );

  const removeItem = useCartStore(
    (state) => state.removeItem
  );

  const setQuantity = useCartStore(
    (state) => state.setQuantity
  );

  const clearCart = useCartStore(
    (state) => state.clearCart
  );

  const total = items.reduce(
    (sum, item) =>
      sum + item.product.price * item.quantity,
    0
  );

  return (
    <section>
      <h2>Shopping Cart</h2>

      {items.length === 0 ? (
        <p>Your cart is empty.</p>
      ) : (
        items.map((item) => (
          <div key={item.product.id}>
            <h3>{item.product.name}</h3>

            <p>
              ${item.product.price} x {item.quantity}
            </p>

            <button
              onClick={() =>
                setQuantity(
                  item.product.id,
                  item.quantity - 1
                )
              }
            >
              -
            </button>

            <button
              onClick={() =>
                setQuantity(
                  item.product.id,
                  item.quantity + 1
                )
              }
            >
              +
            </button>

            <button
              onClick={() => removeItem(item.product.id)}
            >
              Remove
            </button>
          </div>
        ))
      )}

      <h3>Total: ${total.toFixed(2)}</h3>

      <button onClick={clearCart}>
        Clear Cart
      </button>
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Combine the Components

function App() {
  return (
    <main>
      <h1>My Store</h1>

      <ProductList />

      <hr />

      <ShoppingCart />
    </main>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

That's it!

We've built a shared shopping cart without prop drilling or a provider component.

Notice that we calculate total from the cart items instead of storing it separately.

This is intentional.

If we stored both items and total, we would need to keep them synchronized after every change. Derived values are usually better calculated from their source data.


9. Context vs Redux Toolkit vs Zustand

How do these solutions compare?

Feature Context API useReducer + Context Redux Toolkit Zustand
Built into React Yes Yes No No
External dependency No No Yes Yes
Boilerplate Low Medium Medium Low
Centralized state Possible Possible Yes Yes
Selector subscriptions No built-in selector system No built-in selector system Yes Yes
DevTools ecosystem Limited Limited Excellent Available
Async workflows Manual Manual Middleware / RTK Query Manual or integrations
Best for Shared values Structured shared state Complex applications Lightweight shared state

Which One Should You Choose?

Context API

Choose Context when you need to share relatively stable values across a component tree.

useReducer + Context

Choose this when you need more structured update logic without installing another library.

Redux Toolkit

Choose this when your application has complex shared state, multiple feature domains, advanced debugging needs, or benefits from a mature ecosystem.

Zustand

Choose this when you want a lightweight global store with a simple API and selector-based subscriptions.

There is no universally best solution. The right choice depends on your application's complexity and requirements.


10. Server State vs Client State: Don't Mix Them Up

One common mistake is trying to manage API data in the same way as local UI state.

Imagine your application fetches a list of products.

You could store that list in Redux or Zustand, but then you may need to implement:

  • Loading states
  • Error handling
  • Cache expiration
  • Refetching
  • Request deduplication
  • Cache invalidation

These are server-state concerns.

A library such as TanStack Query handles many of them for you.

Example:

import { useQuery } from "@tanstack/react-query";

type Product = {
  id: number;
  name: string;
};

async function fetchProducts(): Promise<Product[]> {
  const response = await fetch("/api/products");

  if (!response.ok) {
    throw new Error("Failed to fetch products");
  }

  return response.json();
}

function Products() {
  const {
    data,
    isPending,
    isError,
  } = useQuery({
    queryKey: ["products"],
    queryFn: fetchProducts,
  });

  if (isPending) return <p>Loading...</p>;

  if (isError) return <p>Something went wrong.</p>;

  return (
    <ul>
      {data.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

A useful architecture might look like this:

React Application
│
├── Local UI State
│   └── useState
│
├── Complex Local State
│   └── useReducer
│
├── Shared Client State
│   ├── Context API
│   ├── Redux Toolkit
│   └── Zustand
│
└── Server State
    ├── TanStack Query
    ├── SWR
    └── RTK Query
Enter fullscreen mode Exit fullscreen mode

The goal isn't to use as many libraries as possible.

The goal is to use the right tool for each kind of state.


11. State Management Best Practices

Here are some principles that help keep React applications maintainable.

1. Keep Local State Local

Don't put every input value or modal visibility flag into a global store.

If only one component needs the state, keep it there.

2. Avoid Unnecessary Global State

Global state creates shared dependencies.

Only promote state when multiple parts of the application genuinely need it.

3. Separate Server State from Client State

Use tools designed for API caching and synchronization when working with remote data.

4. Avoid Duplicate Sources of Truth

Don't store the same information in multiple places unless you have a clear synchronization strategy.

For example, calculate cart totals from cart items instead of storing both independently.

5. Use Selectors

With Redux Toolkit and Zustand, subscribe to only the data your component needs.

This can reduce unnecessary rendering.

6. Organize State by Feature

For larger projects, keep state logic close to the feature that owns it.

src/
  features/
    auth/
      authSlice.ts
      AuthPage.tsx

    cart/
      cartSlice.ts
      CartPage.tsx

    products/
      ProductsPage.tsx

  app/
    store.ts
    hooks.ts
Enter fullscreen mode Exit fullscreen mode

7. Persist Only What You Need

Not every piece of application state should survive a page refresh.

Consider what actually needs persistence and whether the chosen storage mechanism is appropriate.

8. Don't Over-Engineer

A simple useState solution is often better than introducing a global state library too early.

Choose complexity only when your application needs it.


12. Common State Management Mistakes

Let's review some mistakes worth avoiding.

Mistake 1: Making Everything Global

Not every component needs access to the same state.

Mistake 2: Using Context for High-Frequency Updates Without Considering Performance

Context consumers can re-render when the provider value changes. For frequently changing shared data, consider the update patterns and whether a selector-based store would be more appropriate.

Mistake 3: Mutating State Directly

React state and Zustand updates should follow immutable update patterns. Redux Toolkit's reducers are a special case because Immer manages immutable updates for you.

Mistake 4: Storing Derived State

Avoid storing values that can easily be calculated from existing state.

Mistake 5: Confusing Server State with Client State

API caching and synchronization are different problems from managing a modal or theme preference.

Mistake 6: Choosing a Library Before Understanding the Problem

Don't introduce Redux or Zustand simply because another project uses them.

Understand the requirements first.


13. How to Choose the Right State Management Solution

Here's a practical decision guide:

Your Requirement Recommended Solution
State used by one component useState
Complex state transitions in one area useReducer
Shared theme or application settings Context API
Structured shared state without external dependencies useReducer + Context
Large application with complex shared state Redux Toolkit
Lightweight global state Zustand
API data, caching, and synchronization TanStack Query or SWR
API caching within a Redux application RTK Query

Remember:

Start simple. Add a state management library when your application's requirements justify it.


14. Practice Challenge: Build a State-Managed Application

Now it's your turn!

Build a small e-commerce application with these features:

Required Features

  1. A product list.
  2. Add products to a shopping cart.
  3. Remove products from the cart.
  4. Increase and decrease product quantities.
  5. Calculate the total price.
  6. Toggle between light and dark themes.
  7. Store theme preferences between page reloads.

Suggested Architecture

src/
  components/
    ProductList.tsx
    ShoppingCart.tsx
    ThemeToggle.tsx

  stores/
    cartStore.ts
    themeStore.ts

  types/
    index.ts

  App.tsx
Enter fullscreen mode Exit fullscreen mode

Extra Challenges

  • Add product search.
  • Add category filters.
  • Fetch products from an API using TanStack Query.
  • Add authentication UI state.
  • Add a checkout flow.
  • Write tests for your store actions.

Try implementing the project with Zustand first.

Then rebuild the same application using Redux Toolkit.

Comparing both approaches is a great way to understand their differences.


15. Final Thoughts

State management is one of the most important skills for building scalable React applications.

Throughout this article, we explored:

  • React's built-in state tools
  • Lifting state up and prop drilling
  • Context API
  • useReducer with Context
  • Redux Toolkit
  • Zustand
  • Server state management
  • Practical state management patterns
  • Best practices and common mistakes

The most important lesson is that there is no single state management solution for every React application.

Start with React's built-in tools. Understand your application's requirements. Introduce external libraries when they solve a real problem.

Good state management isn't about using the most powerful library.

It's about keeping your application predictable, maintainable, and easy to understand.


React Mastery Series — Complete Learning Path

Congratulations! You've reached the final article of the React Mastery Series.

Here is the complete list of all 11 parts:

  1. Part 1 — Introduction to React & Core Concepts
  2. Part 2 — JSX, Components & Props
  3. Part 3 — State & Event Handling in React
  4. Part 4 — React Hooks: useState, useEffect & Custom Hooks
  5. Part 5 — React Architecture & Best Practices
  6. Part 6 — React Performance Optimization
  7. Part 7 — Working with APIs & Data Fetching in React
  8. Part 8 — React Testing: Unit, Integration & End-to-End Testing
  9. Part 9 — React with TypeScript: Building Type-Safe Applications
  10. Part 10 — React Router: Routing, Nested Routes & Navigation
  11. Part 11 — State Management in React: Context API, Redux Toolkit & Zustand

This list represents the complete React Mastery Series learning path. Add links to the individual articles as they are published so readers can navigate through the entire series.


Thank You for Following the React Mastery Series!

If you've made it through all 11 parts, you've covered a substantial part of the React development journey.

Keep practicing, build real projects, read documentation, and experiment with different architectural approaches.

The best way to master React is to keep building.

Happy Coding! 🚀

React #JavaScript #WebDevelopment #Frontend #Redux #Zustand #TypeScript #ReactJS #Programming #Beginners

Top comments (0)