React advanced topics part two pizza refactoring

June 13, 2024

Refactor Part 1: Split Read and Write Concerns

A small but important improvement is to split context into separate concerns. One simple pattern:

A PizzaStateContext that only exposes the pizza config.

A PizzaActionsContext that only exposes the setters.

This alone doesn’t fully solve the “everything re-renders when pizza changes” problem, but it clarifies the mental model and makes it easier to evolve the architecture.

1// src/context/PizzaContextSplit.tsx
2import React, {
3  createContext,
4  useContext,
5  useState,
6  ReactNode,
7} from 'react';
8
9type PizzaSize = 'small' | 'medium' | 'large';
10type PizzaBase = 'thin' | 'regular' | 'deep-dish';
11
12type ToppingId =
13  | 'pepperoni'
14  | 'mushrooms'
15  | 'onions'
16  | 'olives'
17  | 'extra-cheese';
18
19type PizzaConfig = {
20  size: PizzaSize | null;
21  base: PizzaBase | null;
22  toppings: ToppingId[];
23};
24
25type PizzaStateContextValue = PizzaConfig;
26
27type PizzaActionsContextValue = {
28  setSize: (size: PizzaSize) => void;
29  setBase: (base: PizzaBase) => void;
30  toggleTopping: (topping: ToppingId) => void;
31  reset: () => void;
32};
33
34const PizzaStateContext = createContext<PizzaStateContextValue | undefined>(
35  undefined
36);
37const PizzaActionsContext = createContext<
38  PizzaActionsContextValue | undefined
39>(undefined);
40
41const defaultPizza: PizzaConfig = {
42  size: null,
43  base: null,
44  toppings: [],
45};
46
47export function PizzaProvider({ children }: { children: ReactNode }) {
48  const [pizza, setPizza] = useState<PizzaConfig>(defaultPizza);
49
50  const setSize = (size: PizzaSize) => {
51    setPizza(prev => ({ ...prev, size }));
52  };
53
54  const setBase = (base: PizzaBase) => {
55    setPizza(prev => ({ ...prev, base }));
56  };
57
58  const toggleTopping = (topping: ToppingId) => {
59    setPizza(prev => {
60      const hasTopping = prev.toppings.includes(topping);
61      return {
62        ...prev,
63        toppings: hasTopping
64          ? prev.toppings.filter(t => t !== topping)
65          : [...prev.toppings, topping],
66      };
67    });
68  };
69
70  const reset = () => setPizza(defaultPizza);
71
72  const actions: PizzaActionsContextValue = {
73    setSize,
74    setBase,
75    toggleTopping,
76    reset,
77  };
78
79  return (
80    <PizzaStateContext.Provider value={pizza}>
81      <PizzaActionsContext.Provider value={actions}>
82        {children}
83      </PizzaActionsContext.Provider>
84    </PizzaStateContext.Provider>
85  );
86}
87
88export function usePizzaState() {
89  const ctx = useContext(PizzaStateContext);
90  if (!ctx) {
91    throw new Error('usePizzaState must be used within PizzaProvider');
92  }
93  return ctx;
94}
95
96export function usePizzaActions() {
97  const ctx = useContext(PizzaActionsContext);
98  if (!ctx) {
99    throw new Error('usePizzaActions must be used within PizzaProvider');
100  }
101  return ctx;
102}
103
1// src/components/PizzaSizeSelector.tsx
2import React from 'react';
3import { usePizzaState, usePizzaActions } from '../context/PizzaContextSplit';
4
5export function PizzaSizeSelector() {
6  const { size } = usePizzaState();
7  const { setSize } = usePizzaActions();
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
2// src/components/ToppingsSelector.tsx
3import React from 'react';
4import { usePizzaState, usePizzaActions } from '../context/PizzaContextSplit';
5
6const allToppings = [
7  { id: 'pepperoni', label: 'Pepperoni' },
8  { id: 'mushrooms', label: 'Mushrooms' },
9  { id: 'onions', label: 'Onions' },
10  { id: 'olives', label: 'Olives' },
11  { id: 'extra-cheese', label: 'Extra Cheese' },
12] as const;
13
14export function ToppingsSelector() {
15  const { toppings } = usePizzaState();
16  const { toggleTopping } = usePizzaActions();
17
18  return (
19    <section>
20      <h2>Choose toppings</h2>
21      {allToppings.map(topping => {
22        const selected = toppings.includes(topping.id);
23        return (
24          <button
25            key={topping.id}
26            onClick={() => toggleTopping(topping.id)}
27            aria-pressed={selected}
28          >
29            {topping.label} {selected ? '✓' : ''}
30          </button>
31        );
32      })}
33    </section>
34  );
35}
36
1// src/components/OrderSummary.tsx
2import React, { useMemo } from 'react';
3import { usePizzaState, usePizzaActions } from '../context/PizzaContextSplit';
4
5function calculatePrice(
6  size: 'small' | 'medium' | 'large' | null,
7  base: 'thin' | 'regular' | 'deep-dish' | null,
8  toppings: string[]
9): number {
10  let price = 0;
11
12  if (size === 'small') price += 8;
13  if (size === 'medium') price += 10;
14  if (size === 'large') price += 12;
15
16  if (base === 'thin') price += 0;
17  if (base === 'regular') price += 1;
18  if (base === 'deep-dish') price += 2;
19
20  price += toppings.length * 1.5;
21
22  return price;
23}
24
25export function OrderSummary() {
26  const { size, base, toppings } = usePizzaState();
27  const { reset } = usePizzaActions();
28
29  const price = useMemo(
30    () => calculatePrice(size, base, toppings),
31    [size, base, toppings]
32  );
33
34  const isComplete = Boolean(size && base);
35
36  return (
37    <aside>
38      <h2>Order summary</h2>
39      <p>Size: {size ?? 'Not selected'}</p>
40      <p>Base: {base ?? 'Not selected'}</p>
41      <p>
42        Toppings:{' '}
43        {toppings.length
44          ? toppings.join(', ')
45          : 'No toppings selected'}
46      </p>
47      <p>Total: ${price.toFixed(2)}</p>
48      <button onClick={reset}>Reset</button>
49      <button disabled={!isComplete}>
50        {isComplete ? 'Checkout' : 'Choose size & base first'}
51      </button>
52    </aside>
53  );
54}
55

his is already nicer to read, but we still have one PizzaStateContext whose value is the full pizza object. Changing toppings still forces everyone who calls usePizzaState() to re-render.

Chat Avatar