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}
1031// 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}
241
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}
361// 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}
55his 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.