Getting Started With Redux, Part 3
Welcome back to my series exploring state management with Redux in React applications! Here are part 1 and part 2, in case you missed them or want to revisit. As always, I want to emphasize that the docs for Redux are great, and would encourage anyone who’s curious to check them out. For this installment, though, I want to dive into some tools to help keep your applications more organized and efficient — specifically, I’m going to talk about combining reducers to improve clarity and incorporating middleware to improve functionality. So let’s dive in!
Let’s talk about combining reducers. But first, why should we even bother separating reducers if we’re just going to combine them again? The simple answer is for clarity’s sake. Imagine you have a complex application with 100 different actions, and you need to refactor something. If there’s only one massive reducer, it’s much harder. It’s much easier to separate our reducers by area of responsibility — so, say you have one reducer for user interactions and another reducer for the core functionality of your app.
// without separating reducers, this file will get overwhelming quickly in complex apps
// in this case, I'm separating the user reducerexport default userReducer = (state, action) => {
switch (action.type):
case "LOGIN":
return state.map(user => {
if (user.username === action.payload.username){
return user;
}
if (user.password === action.payload.password){
return {
...user,
isLoggedIn: true
}
}
})
// imagine a bunch of other actions here
default:
return state;}//NOTE: These reducers should be in different files, again just to make your code that much more readable.export default exampleReducer = (state, action) => {
switch (action.type):
case "DO_THE_THING":
return {
...state,
example: action.payload.theThing
}
// imagine a bunch of other actions here
default:
return state;}
Ok, so now we have two reducers — now what? since we can only pass one reducer to our store, we have to combine them first. For this, we use the aptly named combineReducers function. This combined reducer, which by convention we call the rootReducer, is simply an object that uses the reducers we pass into it as keys.
import { createStore, combineReducers } from 'redux';
import userReducer from './userReducer';
import exampleReducer from './exampleReducer';const rootReducer = combineReducers({
users: userReducer,
example: exampleReducer
});const store = createStore(rootReducer)
The new rootReducer has nested the app’s state one level, so now instead of store.user we would have to access store.users.user, but the benefit is much improved organization in our reducers.
So now I want to move on to our next topic: Middleware.
First, what is middleware and why is it important? From the docs: Middleware “provides a third-party extension point between dispatching an action, and the moment it reaches the reducer.” Basically, it lets us add a step before the action we called hits the router. So why is that useful? The most obvious example is for dealing with asynchronous API calls — since the reducer can’t handle a promise, we can use middleware to make sure all our promises are resolved before we pass them along to the reducer. But middleware can also be very useful for logging and crash handling.
So how do we do that? Basically, we pass our middleware, which is a function that returns a function which, in turn, returns a function. The first function takes the store as an argument, the second takes the next function as an argument, and the third and final function takes the action as its argument. It looks very weird at first. Here’s an example of how a logging middleware would look.
const logger = store => next => action => { console.log('dispatching ', action);
let result = next(action); console.log("next state", store.getState())
return result;
}
It looks weird, right? So let’s break it down piece by piece. First, we call a function with the store argument, which connects this logger to the app’s state. That function immediately calls another function, which takes the next argument. The next() function is used as a flow control mechanism. When the middleware has finished whatever we need it to do (in this case, logging the dispatched action and the next state) we return next(action) which sends our action to the next step in the process — whether that’s more middleware or a reducer.
Think of middleware as a gas station on the road between dispatching an action and updating the store. The action stops, we collect whatever data we need, and then return next(action) to get back on the road.
Here’s another example of middleware — the thunk library. You can import it using npm, with:
npm i redux-thunk
And here is what thunk’s code looks like:
function createThunkMiddleware(extraArgument) {
return ({ dispatch, getState }) => (next) => (action) => {
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument);
} return next(action);
}
;} const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware; export default thunk;
That’s it. 14 lines. And the same pattern as the logging middleware above. Thunk is useful for simplifying writing middleware, but I wanted to emphasize how simple it is at it’s core. In order to make an API call with thunk, for example, we could write something like this:
const doTheThing = (example) => (dispatch) => {
return apiCall().then(data => dispatch(example, data))
}
In this scenario, we can wait for our promise to resolve before sending it to the reducer. This lets us populate our state with data from API calls simply and efficiently.
Connecting middleware to our store is also incredibly easy. We simply import the applyMiddleware function from redux and then pass it as a second argument to our store. Here’s how you do it (using our example from above and thunk).
import { createStore, combineReducers, applyMiddleware} from 'redux';
import thunk from 'redux-thunk';
import userReducer from './userReducer';
import exampleReducer from './exampleReducer';const rootReducer = combineReducers({
users: userReducer,
example: exampleReducer
});const store = createStore(rootReducer, applyMiddleware(thunk))
Just like that, we’re all set.
In the interest of brevity, I’ll wrap up this post here. But thank you for reading, and I hope you’re feeling a little more comfortable about using middleware and combining reducers!