React advanced topics part two Co-locate State

June 13, 2024

Refactor Part 2: Co-locate State and Use Multiple Contexts

In many real apps, the biggest win comes from not putting everything in a single context at all. Instead:

Local UI state stays local (isToppingsPanelOpen, isSizePopoverOpen, etc.).

Shared state is split into smaller contexts per concern.

For our Pizza Builder, a reasonable split could be:

PizzaConfigContext for the actual pizza choice (size, base, toppings).

PricingContext (or just a hook) for derived pricing logic.

Local state inside components for any purely visual toggles.

Here’s an example where size/base and toppings are separate contexts. This is a bit more verbose, but demonstrates the pattern.

1
2// src/context/PizzaSizeBaseContext.tsx
3import React, {
4  createContext,
5  useContext,
6  useState,
7  ReactNode,
8} from 'react';
9
10type PizzaSize = 'small' | 'medium' | 'large';
11type PizzaBase = 'thin' | 'regular' | 'deep-dish';
12
13type SizeBaseState = {
14  size: PizzaSize | null;
15  base: PizzaBase | null;
16};
17
18type SizeBaseContextValue = {
19  state: SizeBaseState;
20  setSize: (size: PizzaSize) => void;
21  setBase: (base: PizzaBase) => void;
22};
23
24const SizeBaseContext = createContext<SizeBaseContextValue | undefined>(
25  undefined
26);
27
28export function SizeBaseProvider({ children }: { children: ReactNode }) {
29  const [state, setState] = useState<SizeBaseState>({
30    size: null,
31    base: null,
32  });
33
34  const setSize = (size: PizzaSize) => {
35    setState(prev => ({ ...prev, size }));
36  };
37
38  const setBase = (base: PizzaBase) => {
39    setState(prev => ({ ...prev, base }));
40  };
41
42  return (
43    <SizeBaseContext.Provider value={{ state, setSize, setBase }}>
44      {children}
45    </SizeBaseContext.Provider>
46  );
47}
48
49export function useSizeBase() {
50  const ctx = useContext(SizeBaseContext);
51  if (!ctx) {
52    throw new Error('useSizeBase must be used within SizeBaseProvider');
53  }
54  return ctx;
55}
56
1// src/context/PizzaToppingsContext.tsx
2import React, {
3  createContext,
4  useContext,
5  useState,
6  ReactNode,
7} from 'react';
8
9type ToppingId =
10  | 'pepperoni'
11  | 'mushrooms'
12  | 'onions'
13  | 'olives'
14  | 'extra-cheese';
15
16type ToppingsContextValue = {
17  toppings: ToppingId[];
18  toggleTopping: (topping: ToppingId) => void;
19  resetToppings: () => void;
20};
21
22const ToppingsContext = createContext<ToppingsContextValue | undefined>(
23  undefined
24);
25
26export function ToppingsProvider({ children }: { children: ReactNode }) {
27  const [toppings, setToppings] = useState<ToppingId[]>([]);
28
29  const toggleTopping = (topping: ToppingId) => {
30    setToppings(prev => {
31      const hasTopping = prev.includes(topping);
32      return hasTopping
33        ? prev.filter(t => t !== topping)
34        : [...prev, topping];
35    });
36  };
37
38  const resetToppings = () => setToppings([]);
39
40  return (
41    <ToppingsContext.Provider
42      value={{ toppings, toggleTopping, resetToppings }}
43    >
44      {children}
45    </ToppingsContext.Provider>
46  );
47}
48
49export function useToppings() {
50  const ctx = useContext(ToppingsContext);
51  if (!ctx) {
52    throw new Error('useToppings must be used within ToppingsProvider');
53  }
54  return ctx;
55}
56

Now the page composition becomes:

1// src/pages/pizza-builder-optimized.tsx
2import React from 'react';
3import { SizeBaseProvider } from '../context/PizzaSizeBaseContext';
4import { ToppingsProvider } from '../context/PizzaToppingsContext';
5import { PizzaSizeSelector } from '../components/PizzaSizeSelectorOptimized';
6import { ToppingsSelector } from '../components/ToppingsSelectorOptimized';
7import { OrderSummaryOptimized } from '../components/OrderSummaryOptimized';
8
9export default function PizzaBuilderOptimizedPage() {
10  return (
11    <SizeBaseProvider>
12      <ToppingsProvider>
13        <main>
14          <h1>Build your pizza (optimized)</h1>
15          <PizzaSizeSelector />
16          <ToppingsSelector />
17          <OrderSummaryOptimized />
18        </main>
19      </ToppingsProvider>
20    </SizeBaseProvider>
21  );
22}
23

And consumers:

1// src/components/PizzaSizeSelectorOptimized.tsx
2import React from 'react';
3import { useSizeBase } from '../context/PizzaSizeBaseContext';
4
5export function PizzaSizeSelector() {
6  const { state, setSize } = useSizeBase();
7  const { size } = state;
8
9  return (
10    <section>
11      <h2>Choose size</h2>
12      {(['small', 'medium', 'large'] as const).map(s => (
13        <button
14          key={s}
15          onClick={() => setSize(s)}
16          aria-pressed={size === s}
17        >
18          {s}
19        </button>
20      ))}
21    </section>
22  );
23}
24
1// src/components/ToppingsSelectorOptimized.tsx
2import React from 'react';
3import { useToppings } from '../context/PizzaToppingsContext';
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 { toppings, toggleTopping } = useToppings();
15
16  return (
17    <section>
18      <h2>Choose toppings</h2>
19      {allToppings.map(topping => {
20        const selected = 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/OrderSummaryOptimized.tsx
3import React, { useMemo } from 'react';
4import { useSizeBase } from '../context/PizzaSizeBaseContext';
5import { useToppings } from '../context/PizzaToppingsContext';
6
7function calculatePrice(
8  size: 'small' | 'medium' | 'large' | null,
9  base: 'thin' | 'regular' | 'deep-dish' | null,
10  toppings: string[]
11): number {
12  let price = 0;
13
14  if (size === 'small') price += 8;
15  if (size === 'medium') price += 10;
16  if (size === 'large') price += 12;
17
18  if (base === 'thin') price += 0;
19  if (base === 'regular') price += 1;
20  if (base === 'deep-dish') price += 2;
21
22  price += toppings.length * 1.5;
23
24  return price;
25}
26
27export function OrderSummaryOptimized() {
28  const { state } = useSizeBase();
29  const { toppings, resetToppings } = useToppings();
30
31  const { size, base } = state;
32
33  const price = useMemo(
34    () => calculatePrice(size, base, toppings),
35    [size, base, toppings]
36  );
37
38  const isComplete = Boolean(size && base);
39
40  const handleReset = () => {
41    // reset size/base
42    // You can either lift a reset handler into a combined provider
43    // or call separate reset hooks; here we just show the idea.
44    window.location.reload(); // placeholder, see discussion below
45  };
46
47  return (
48    <aside>
49      <h2>Order summary</h2>
50      <p>Size: {size ?? 'Not selected'}</p>
51      <p>Base: {base ?? 'Not selected'}</p>
52      <p>
53        Toppings:{' '}
54        {toppings.length
55          ? toppings.join(', ')
56          : 'No toppings selected'}
57      </p>
58      <p>Total: ${price.toFixed(2)}</p>
59      <button onClick={handleReset}>Reset</button>
60      <button disabled={!isComplete}>
61        {isComplete ? 'Checkout' : 'Choose size & base first'}
62      </button>
63    </aside>
64  );
65}
66

When to Move to a Dedicated Store Context works well for:

  • A few shared values that many components need

  • Mostly read-heavy data with modest update frequency

  • Once your Pizza Builder turns into a full restaurant management system with:

  • A cart with many pizzas

  • User profiles

  • Inventory and admin panels

  • Analytics and cross-page state

The mental model is the same as what we’ve practiced here: think in slices and owners. Context is the first step; a store is a scaling strategy when your pizza shop grows into a full platform.

Happy coding!

Chat Avatar