React advanced state management

May 16, 2024

Context, state, and re-renders are where a “simple” React app quietly turns into a pizza kitchen with too many cooks shouting orders at each other. In small apps you can get away with passing props down a couple of levels, but once you have a full Gatsby site with layouts, sections, widgets, and modals, you need a deliberate strategy for where state lives, who owns it, and how far its updates should ripple through the tree. Context, external stores (like Zustand or Redux Toolkit), and local component state all solve different problems, and choosing the wrong one can make every tiny change (like “extra cheese toggled”) trigger a cascade of pointless re-renders across your entire UI. React's virtual DOM mechanism effectively updates the DOM, but needless re-renders can still cause performance issues. Re-rendering can be optimized to make sure that only the components that require it re-render, which improves application performance and responsiveness.

Understanding Re-Rendering in React

React's rendering process revolves around its component tree. When a component's state or props change, React re-renders that component and its child components. However, if not managed properly, this can lead to unnecessary re-renders, where components that didn’t experience any real changes also get re-rendered, wasting resources. State management refers to the process of managing the state of an application. The state is a piece of an object that holds the data that can change over time and affect the looks(rendering) and behavior of the application. React allows us to create dynamic web applications. Managing state in React can be a bit tricky, especially if you’re new to it, as it has multiple approaches to achieve state management rather than one solution. It provides built-in features like the “useState” hook for managing state at the component level and the Context API for global state management. Additionally, there is a pool of external libraries like Redux to handle more complex state management needs. Why do we need state management solutions? State management solutions are essential for addressing issues like prop drilling and data sharing between components. Prop drilling occurs when you need to pass state through many levels of components, which can make the codebase hard to maintain and understand.

Additionally, when multiple components need to share data, managing this state without a proper solution can become cumbersome and lead to inconsistencies. Effective state management solutions help to centralize and streamline state handling, making the application more predictable and easier to debug.

Context API

What is the Context API? The Context API in React enables you to share data across multiple components without manually passing props down through each level of the component tree.

When to Use Context API The Context API is best suited for scenarios where you need to share state across a small to medium-sized application (sometimes in more complex scenarios as well). It is more suitable for storing pieces of data like themes, localization, authentication state etc. that doesn’t change frequently.

Advantages & Disadvantages of Context API Advantages:

Easy to implement and use Already built into React Simple syntax with less boilerplate

For this guide we’ll build a Pizza Builder where users compose their own pizza: size, base, toppings, and maybe a live price breakdown. In the naïve version, we’ll stick all the pizza state into one giant context at the top of the app and let every component subscribe to everything, so changing a single topping re-renders the entire page. Then we’ll refactor it: splitting context by concern (cart vs. pizza configuration), co-locating fast-changing state closer to where it’s used, and using memoization and context-splitting patterns so that the “Order Summary” doesn’t re-render just because the user hovers over a topping option.

By the end, you’ll see three concrete patterns:

Local component state for purely UI concerns (like “is the toppings panel open?”). ​ Lean, read-focused contexts for shared configuration (like the current pizza and selected toppings) instead of a “global mutable blob”.

When and why to graduate to a dedicated state library (Zustand/Redux) once your “pizza shop” grows into a full restaurant management system with inventory, users, and cross-page analytics. Every step will come with Pizza Builder code examples in React 18 style (function components, hooks, no GraphQL), so you can drop them straight into your Gatsby v5 project and actually feel the difference in render behavior as you slice and serve your state.

The Pizza Builder: Requirements

We’ll work with a very concrete UI:

  • A PizzaSizeSelector (Small, Medium, Large)
  • A PizzaBaseSelector (Thin, Regular, Deep Dish)
  • A ToppingsSelector (list of toppings, each toggleable)
  • An OrderSummary (selected options + price)
  • A CheckoutButton (disabled until pizza is valid)

Conceptually, our state looks like this:

1type PizzaSize = 'small' | 'medium' | 'large';
2type PizzaBase = 'thin' | 'regular' | 'deep-dish';
3
4type ToppingId =
5  | 'pepperoni'
6  | 'mushrooms'
7  | 'onions'
8  | 'olives'
9  | 'extra-cheese';
10
11type PizzaConfig = {
12  size: PizzaSize | null;
13  base: PizzaBase | null;
14  toppings: ToppingId[];
15};
16

We need:

To update this config from multiple components

To compute a price from the current config

To avoid re-rendering everything on every tiny change

Naïve Version: One Giant Context for Everything The usual “first attempt” is a single context at the top that holds all pizza state and all updater functions. This is simple to reason about, but it has a hidden cost: every consumer re-renders whenever any part of the context value changes.

Step 1: The Giant Pizza Context

1
2
3// src/context/PizzaContext.tsx
4import React, { createContext, useContext, useState, ReactNode } from 'react';
5
6type PizzaSize = 'small' | 'medium' | 'large';
7type PizzaBase = 'thin' | 'regular' | 'deep-dish';
8
9type ToppingId =
10  | 'pepperoni'
11  | 'mushrooms'
12  | 'onions'
13  | 'olives'
14  | 'extra-cheese';
15
16type PizzaConfig = {
17  size: PizzaSize | null;
18  base: PizzaBase | null;
19  toppings: ToppingId[];
20};
21
22type PizzaContextValue = {
23  pizza: PizzaConfig;
24  setSize: (size: PizzaSize) => void;
25  setBase: (base: PizzaBase) => void;
26  toggleTopping: (topping: ToppingId) => void;
27  reset: () => void;
28};
29
30const PizzaContext = createContext<PizzaContextValue | undefined>(undefined);
31
32const defaultPizza: PizzaConfig = {
33  size: null,
34  base: null,
35  toppings: [],
36};
37
38export function PizzaProvider({ children }: { children: ReactNode }) {
39  const [pizza, setPizza] = useState<PizzaConfig>(defaultPizza);
40
41  const setSize = (size: PizzaSize) => {
42    setPizza(prev => ({ ...prev, size }));
43  };
44
45  const setBase = (base: PizzaBase) => {
46    setPizza(prev => ({ ...prev, base }));
47  };
48
49  const toggleTopping = (topping: ToppingId) => {
50    setPizza(prev => {
51      const hasTopping = prev.toppings.includes(topping);
52      return {
53        ...prev,
54        toppings: hasTopping
55          ? prev.toppings.filter(t => t !== topping)
56          : [...prev.toppings, topping],
57      };
58    });
59  };
60
61  const reset = () => setPizza(defaultPizza);
62
63  const value: PizzaContextValue = {
64    pizza,
65    setSize,
66    setBase,
67    toggleTopping,
68    reset,
69  };
70
71  return (
72    <PizzaContext.Provider value={value}>
73      {children}
74    </PizzaContext.Provider>
75  );
76}
77
78export function usePizza() {
79  const ctx = useContext(PizzaContext);
80  if (!ctx) {
81    throw new Error('usePizza must be used within a PizzaProvider');
82  }
83  return ctx;
84}
85

Step 2: Components That Use the Context

1// src/components/PizzaSizeSelector.tsx
2import React from 'react';
3import { usePizza } from '../context/PizzaContext';
4
5export function PizzaSizeSelector() {
6  const { pizza, setSize } = usePizza();
7
8  return (
9    <section>
10      <h2>Choose size</h2>
11      {(['small', 'medium', 'large'] as const).map(size => (
12        <button
13          key={size}
14          onClick={() => setSize(size)}
15          aria-pressed={pizza.size === size}
16        >
17          {size}
18        </button>
19      ))}
20    </section>
21  );
22}
23
1// src/components/ToppingsSelector.tsx
2import React from 'react';
3import { usePizza } from '../context/PizzaContext';
4
5const allToppings = [
6  { id: 'pepperoni', label: 'Pepperoni' },
7  { id: 'mushrooms', label: 'Mushrooms' },
8  { id: 'onions', label: 'Onions' },
9  { id: 'olives', label: 'Olives' },
10  { id: 'extra-cheese', label: 'Extra Cheese' },
11] as const;
12
13export function ToppingsSelector() {
14  const { pizza, toggleTopping } = usePizza();
15
16  return (
17    <section>
18      <h2>Choose toppings</h2>
19      {allToppings.map(topping => {
20        const selected = pizza.toppings.includes(topping.id);
21        return (
22          <button
23            key={topping.id}
24            onClick={() => toggleTopping(topping.id)}
25            aria-pressed={selected}
26          >
27            {topping.label} {selected ? '✓' : ''}
28          </button>
29        );
30      })}
31    </section>
32  );
33}
34
1
2// src/components/OrderSummary.tsx
3import React, { useMemo } from 'react';
4import { usePizza } from '../context/PizzaContext';
5
6function calculatePrice(size: any, base: any, toppings: any[]): number {
7  let price = 0;
8
9  if (size === 'small') price += 8;
10  if (size === 'medium') price += 10;
11  if (size === 'large') price += 12;
12
13  if (base === 'thin') price += 0;
14  if (base === 'regular') price += 1;
15  if (base === 'deep-dish') price += 2;
16
17  price += toppings.length * 1.5;
18
19  return price;
20}
21
22export function OrderSummary() {
23  const { pizza, reset } = usePizza();
24  const { size, base, toppings } = pizza;
25
26  const price = useMemo(
27    () => calculatePrice(size, base, toppings),
28    [size, base, toppings]
29  );
30
31  const isComplete = Boolean(size && base);
32
33  return (
34    <aside>
35      <h2>Order summary</h2>
36      <p>Size: {size ?? 'Not selected'}</p>
37      <p>Base: {base ?? 'Not selected'}</p>
38      <p>
39        Toppings:{' '}
40        {toppings.length
41          ? toppings.join(', ')
42          : 'No toppings selected'}
43      </p>
44      <p>Total: ${price.toFixed(2)}</p>
45      <button onClick={reset}>Reset</button>
46      <button disabled={!isComplete}>
47        {isComplete ? 'Checkout' : 'Choose size & base first'}
48      </button>
49    </aside>
50  );
51}
52
53

And the page:

1// src/pages/pizza-builder.tsx
2import React from 'react';
3import { PizzaProvider } from '../context/PizzaContext';
4import { PizzaSizeSelector } from '../components/PizzaSizeSelector';
5import { ToppingsSelector } from '../components/ToppingsSelector';
6import { OrderSummary } from '../components/OrderSummary';
7
8export default function PizzaBuilderPage() {
9  return (
10    <PizzaProvider>
11      <main>
12        <h1>Build your pizza</h1>
13        <PizzaSizeSelector />
14        <ToppingsSelector />
15        <OrderSummary />
16      </main>
17    </PizzaProvider>
18  );
19}
20

This works, but there’s a subtle performance issue: every time any part of pizza changes, all three components re-render. Even if you just add a topping, the size selector re-renders. Even if you change size, the toppings list re-renders.

For a toy pizza page this is fine, but for a large Gatsby app with many widgets hanging off a big context, this pattern scales poorly.

The Core Problem: Context Value as a Single Changing Object The reason is simple: PizzaContext.Provider receives a value object that contains pizza and all the setters. On every state change, pizza changes identity, and therefore the value object changes identity. All consumers re-render. You can’t “memo” your way out of this at the consumer level if they all use the same context.

The questions we want to answer:

Can we narrow which components re-render when?

Can we keep the API ergonomic without turning everything into prop-drilling?

When does it make sense to split things into a store (like Zustand) instead? This we will find out in the next guide.

Chat Avatar