Redux from scratch, then with Redux Toolkit
September 25, 2023 · 8 min read
I assumed Redux was something you reach for the moment an app gets serious. It isn't. Redux solves one specific problem, and until I could say which one, I didn't know when to use it.
Three kinds of state
Local state belongs to one component, like an input's text. useState is enough. Cross-component state affects a few components, like a button that opens a modal somewhere else. You can pass props down and callbacks back up, though it gets tedious. App-wide state touches almost everything, like whether someone is logged in and which parts of the navigation that changes.
The last two are where passing props around hurts. React Context already helps with that, so why Redux?
Where Context starts to strain
I wrote "potential" next to both of these in my old notes, and I still think that's right. If they don't bite you, you don't need Redux. You can even use both in one app.
The first is structure. In a large app you either nest many providers, or you put everything into one giant provider that becomes hard to maintain. The second is speed. Context is fine for things that change rarely, like a theme or login status. It is a poor fit for data that updates all the time.
The core idea
One store holds all the state. Nobody edits it directly. The cycle is always the same.
A component dispatches an action, which is a plain object describing what happened. The reducer takes the current state and that action and returns the next state. Components that subscribed to the store get told, and they run again with the new value. A reducer has to be pure: same input, same output, no HTTP calls, no local storage. It only computes.
Redux with no React at all
I found it easier to see this in a plain Node file first, because Redux has nothing to do with React.
const redux = require("redux");
const counterReducer = (state = { counter: 0 }, action) => {
if (action.type === "increment") {
return { counter: state.counter + 1 };
}
if (action.type === "decrement") {
return { counter: state.counter - 1 };
}
return state;
};
const store = redux.createStore(counterReducer);
store.subscribe(() => {
console.log(store.getState());
});
store.dispatch({ type: "increment" });There is a default value on state for a reason. When the store is created, Redux calls the reducer once by itself to get the initial state, and at that point there is no state yet. In my first version the reducer added one on every call regardless of the action, and the counter came out as 2. The first one was Redux initialising, the second was my dispatch. With the if checks, the setup call falls through to return state and the counter starts at 0.
Hooking it up to React
Export the store, then wrap the app once in Provider from react-redux. Anything below it can reach the store.
<Provider store={store}>
<App />
</Provider>In a component, useSelector picks the slice you want, and it also subscribes the component for you. When that data changes the component re-runs, and when the component unmounts the subscription is cleaned up. useDispatch returns the function that sends actions.
const dispatch = useDispatch();
const counter = useSelector((state) => state.counter);
dispatch({ type: "increase", amount: 5 });The action can carry any extra fields you like. The reducer reads action.amount and adds it.
Return the whole state, never mutate it
When I added showCounter next to counter, the counter vanished after the first increment. The reducer only returned { counter }. Redux doesn't merge what you return. It replaces the state with it, so showCounter became undefined. Every branch has to spell out the full object.
The other trap is more annoying, because it works. Writing state.counter++; return state; makes my demo behave correctly. But objects are reference values, so you are editing the old snapshot in place. In a bigger app that gives you state and UI that drift apart and bugs that are hard to trace. The rule is to always return a new object.
Redux Toolkit
Doing this by hand has three real costs. Action type strings are easy to mistype. Every branch copies all the state it isn't changing. And immutability is on you, especially with nested data. Redux Toolkit is from the Redux team and removes all three.
const counterSlice = createSlice({
name: "counter",
initialState: { counter: 0, showCounter: true },
reducers: {
increment(state) {
state.counter++;
},
increase(state, action) {
state.counter += action.payload;
},
toggleCounter(state) {
state.showCounter = !state.showCounter;
},
},
});
export const counterActions = counterSlice.actions;The part I had to get my head around: that looks like mutation, and it is allowed here. Toolkit uses immer underneath, which watches what you change and builds a new immutable state from it. I only write the change. The other fields are kept for me. There is no if on action.type either, because each method is called for its own action.
I also don't invent type strings anymore. counterActions.increase(5) is an action creator, and it produces the whole object, with a generated type and the 5 under payload. That is why the reducer reads action.payload instead of my old amount.
const store = configureStore({
reducer: { counter: counterSlice.reducer, auth: authSlice.reducer },
});
dispatch(counterActions.increase(5));
const counter = useSelector((state) => state.counter.counter);Redux wants one main reducer. configureStore merges the slices into one when you give it an object. With a single slice you can pass its reducer directly. With several, remember the extra level when you select: state.counter.counter, not state.counter.
Where side effects go
Reducers stay pure and synchronous, so a fetch can't live in one. There are two places for it. One is in the component, for example in useEffect, and you dispatch once the work is done, so Redux never knows about it. The other is to write your own action creators that do the async work before dispatching. Either way the reducers stay untouched.
What stuck with me: the reducer only ever turns an old snapshot and an action into a new snapshot, and everything else in Redux exists to make that safe and easy.