createSlice is where the boilerplate goes away. Give it a name, an initial state and an object of case reducers; it returns a reducer, one action creator per case (typed name/caseName), and — since Redux Toolkit 2.0 3,824 — selectors bound to the slice.
const cartSlice = createSlice({
name: 'cart', initialState: { lines: [] },
reducers: {
added(state, action) { // Immer: write as if it were mutable
const line = state.lines.find(l => l.id === action.payload.id);
if (line) line.qty += 1; else state.lines.push({ ...action.payload, qty: 1 });
},
removed: (s, a) => { s.lines = s.lines.filter(l => l.id !== a.payload); },
},
selectors: { selectTotal: s => s.lines.reduce((t, l) => t + l.price * l.qty, 0) },
});
const { added, removed } = cartSlice.actions, { selectTotal } = cartSlice.selectors;
const store = configureStore({ reducer: { cart: cartSlice.reducer } });
store.dispatch(added({ id: 1, name: 'Mug', price: 9 })); // { type: 'cart/added', payload }
const before = store.getState().cart.lines[0];
store.dispatch(added({ id: 2, name: 'Cap', price: 14 })); store.dispatch(removed(2));
console.log(store.getState().cart.lines, 'total', selectTotal(store.getState()));
console.log('Mug object reused?', before === store.getState().cart.lines[0]);[ { id: 1, name: 'Mug', price: 9, qty: 1 } ] total 9
Mug object reused? truestate.lines.push(...) is not a mutation of your state. Redux Toolkit wraps every case reducer in Immer 28,983 , which hands the function a draft proxy, records the writes, and produces a new immutable object with structural sharing. The last line proves it: adding and removing the cap left the mug's object identical by reference, so a memoized row does not re-render. Immer's one rule: either mutate the draft or return a new value, never both, and never state = [], which does nothing — write state.lines = [].
One more field matters: extraReducers handles actions owned by other slices — logout clearing the cart, or a thunk's fulfilled action (createAsyncThunk).