Pattern that suck (and what to do instead)
Let's talk about a pattern I see everywhere in React - and why I think it often does more harm than good.
We'll use shopping cart as an example. You know the drill, users can:
- Add items
- Remove items
- Increase/decrease quantity
Seems simple enough, right?
Now here's the question:
Would you rather build this with one giant
switch
statement, or use a set of functions that each do
one thing?
Let's walk through both.
The “One function to rule them all” pattern (aka reducer)
The general premise is to have an action and switch based on its type.
interface CartItem {
id: string;
quantity: number;
name: string;
}
type CartAction =
| { readonly type: "ADD_ITEM"; payload: CartItem }
| { readonly type: "REMOVE_ITEM"; payload: { id: string } }
| { readonly type: "INCREASE_QUANTITY"; payload: { id: string } }
| { readonly type: "DECREASE_QUANTITY"; payload: { id: string } };
type CartState = CartItem[];
function reducer(items: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD_ITEM': {
const existing = items.find(item => item.id === action.payload.id);
if (existing) {
return items.map(item =>
item.id === action.payload.id
? { ...item, quantity: item.quantity + 1 }
: item
),
}
return [...items, { ...action.payload, quantity: 1 }];
}
case 'REMOVE_ITEM': {
return items.filter(item => item.id !== action.payload.id);
}
case 'INCREASE_QUANTITY': {
return items.map(item =>
item.id === action.payload.id
? { ...item, quantity: item.quantity + 1 }
: item
);
}
case 'DECREASE_QUANTITY': {
return items
.map(item =>
item.id === action.payload.id
? { ...item, quantity: item.quantity - 1 }
: item
)
.filter(item => item.quantity > 0)
}
// There are a few ways to handle an exhaustive switch, but all of them are kind of annoying
}
return items;
}
function useCart(initial: CartState = []) {
const [state, dispatch] = useReducer(reducer, initial)
return [state, dispatch]
}
Then you use it like this:
<ul>
{items.map(item => (
<li key={item.id}>
{item.name} × {item.quantity} – ${(item.price * item.quantity).toFixed(2)}
<button onClick={() => dispatch({ type: "INCREASE_QUANTITY", payload: { id: item.id } })}>+</button>
<button onClick={() => dispatch({ type: "DECREASE_QUANTITY", payload: { id: item.id } })}>-</button>
<button onClick={() => dispatch({ type: "REMOVE_ITEM", payload: { id: item.id } })}>Remove</button>
</li>
))}
</ul>
Seems okay... but now imagine 10 more actions, 3 more conditions per case, and some async validation.
IMHO it's not pleasant to work with.
The other way: just use functions
Now let's see what happens if we stop pretending this is a Redux app from 2016 and just write normal code:
function useCart(initial: CartItem[] = []) {
const [cart, setCart] = useState<CartItem[]>(initial);
const addItem = (item: CartItem) => {
setCart(prev => {
const existing = prev.find(p => p.id === item.id);
if (existing) {
return prev.map(p =>
p.id === item.id ? { ...p, quantity: p.quantity + 1 } : p
);
}
return [...prev, { ...item, quantity: 1 }];
});
};
const removeItem = (id: string) => {
setCart(prev => prev.filter(p => p.id !== id));
};
const increaseQuantity = (id: string) => {
setCart(prev =>
prev.map(p =>
p.id === id ? { ...p, quantity: p.quantity + 1 } : p
)
);
};
const decreaseQuantity = (id: string) => {
setCart(prev =>
prev
.map(p =>
p.id === id ? { ...p, quantity: p.quantity - 1 } : p
)
.filter(p => p.quantity > 0)
);
};
return {
cart,
addItem,
removeItem,
increaseQuantity,
decreaseQuantity,
};
}
Now your usage looks like this:
<ul>
{cart.map(item => (
<li key={item.id}>
{item.name} × {item.quantity} – ${(item.price * item.quantity).toFixed(2)}
<button onClick={() => increaseQuantity(item.id)}>+</button>
<button onClick={() => decreaseQuantity(item.id)}>-</button>
<button onClick={() => removeItem(item.id)}>Remove</button>
</li>
))}
</ul>
Why is this better?
- Easier to reason about and read stack trace
- Easier to use with TypeScript
- Less potential for bugs (like missing case in switch if you haven't bothered with TypeScript setup for exhaustive switch)
- Regular function names instead of “dispatch a thing with a type of...”, less code and less indirection
So... Why Even Use Reducers?
People say it's better for bigger state, but honestly? The only real difference here is the switch statement versus calling smaller functions directly. Optimization? The React compiler will add useCallbacks and performance should be the same, unless I'm missing something? So you probably should never use reducers.
Agree? Disagree? Let me know.