Refactor TodoMVC with Redux Starter Kit
I've worked with React for over two years. I started on a large project that already used Redux. Jumping into so much existing code felt overwhelming, especially with an unfamiliar framework. Over time, I grew comfortable and experienced.
Recently I discovered Redux Starter Kit
from the Redux team. This toolset provides utilities that simplify working with
Redux. One tool, createReducer, follows a pattern I've used for a while. It
reduces boilerplate and speeds up development, especially in new projects.
To learn the toolset, I migrated an existing Redux codebase. For my example, I chose the omnipresent TodoMVC, specifically the version from the Redux repository.
Starting point
The app has two main reducers: visibilityFilter and todos. Each has its own
actions, action creators, and selectors.
Visibility Filter
I started with the simpler reducer, then moved to the more complex one.
Reducer
The reducer from the Redux example is already simple and clear.
// reducers/visibilityFilter.jsimport { SET_VISIBILITY_FILTER } from "../constants/ActionTypes";import { SHOW_ALL } from "../constants/TodoFilters";export default (state = SHOW_ALL, action) => {switch (action.type) {case SET_VISIBILITY_FILTER:return action.filter;default:return state;}};
Redux Starter Kit provides createReducer for creating reducers. As I
mentioned, I already use this pattern and find it effective.
Instead of creating a reducer with a switch case statement, you pass the
initial state as the first parameter and an object mapping action types to
reducer functions ((state, action) => { /* reducer code */}).
It reduces boilerplate and automatically handles the default case with
return state. The biggest benefit: improved readability.
Here is the visibility filter reducer using createReducer:
// reducers/visibilityFilter.jsimport { createReducer } from "redux-starter-kit";import { SET_VISIBILITY_FILTER } from "../constants/ActionTypes";import { SHOW_ALL } from "../constants/TodoFilters";export default createReducer(SHOW_ALL, {[SET_VISIBILITY_FILTER]: (state, action) => action.filter,});
Actions creators
Now for the actions. The visibility filter has one action,
SET_VISIBILITY_FILTER, with a simple creator:
// actions/index.jsimport * as types from "../constants/ActionTypes";/* ... Other actions ...*/export const setVisibilityFilter = (filter) => ({type: types.SET_VISIBILITY_FILTER,filter,});
The toolset provides createAction, which takes only the action type as a
parameter and returns an action creator.
// actions/index.jsimport * as types from "../constants/ActionTypes";/* ... Other actions ...*/export const setVisibilityFilter = createAction(types.SET_VISIBILITY_FILTER);
This action creator accepts optional parameters. Any argument becomes the action's payload:
const setVisibilityFilter = createAction("SET_VISIBILITY_FILTER");let action = setVisibilityFilter();// { type: 'SET_VISIBILITY_FILTER' }action = setVisibilityFilter("SHOW_COMPLETED");// returns { type: 'SET_VISIBILITY_FILTER', payload: 'SHOW_COMPLETED' }setVisibilityFilter.toString();// 'SET_VISIBILITY_FILTER'
Now the filter uses the payload key instead of filter. This requires a small
reducer change:
// reducers/visibilityFilter.jsimport { createReducer } from "redux-starter-kit";import { SET_VISIBILITY_FILTER } from "../constants/ActionTypes";import { SHOW_ALL } from "../constants/TodoFilters";export default createReducer(SHOW_ALL, {[SET_VISIBILITY_FILTER]: (state, action) => action.payload,});
Selectors
Selectors are one of the best choices when working with React. They let you refactor state structure without changing every component that consumes it.
The visibility filter selector is straightforward:
// selectors/index.jsconst getVisibilityFilter = (state) => state.visibilityFilter;/* ... Other selectors ...*/
Using createSelector adds a bit more code, but the payoff comes soon. Keep
reading.
// selectors/index.jsimport { createSelector } from "redux-starter-kit";const getVisibilityFilter = createSelector(["visibilityFilter"]);/* ... Other selectors ...*/
Slices
So far, we've replaced simple functions with simpler ones using various
creators. Now comes the real power of the toolset: createSlice.
createSlice accepts an initial state, reducer functions, and an optional slice
name. It automatically generates action creators, action types, and selectors.
Now we can discard all the previous code.
Creating a slice for the visibility filter is clean and eliminates significant boilerplate.
// ducks/visibilityFilter.jsimport { createSlice } from "redux-starter-kit";export default createSlice({slice: "visibilityFilter",initialState: SHOW_ALL,reducers: {setVisibilityFilter: (state, action) => action.payload,},});
The result is a single object containing everything needed to work with Redux:
const reducer = combineReducers({visibilityFilter: visibilityFilter.reducer,});const store = createStore(reducer);store.dispatch(visibilityFilter.actions.setVisibilityFilter(SHOW_COMPLETED));// -> { visibilityFilter: 'SHOW_COMPLETED' }const state = store.getState();console.log(visibilityFilter.selectors.getVisibilityFilter(state));// -> SHOW_COMPLETED
See all changes so far in this commit.
Todos
The todos reducer is more complex, so I'll explain the final result rather than each step. See the complete code here.
First, define the initial state:
// ducks/todos.jsconst initialState = [{text: "Use Redux",completed: false,id: 0,},];
To improve readability, I extracted each reducer action into its own function:
// ducks/todos.jsconst addTodo = (state, action) => [...state,{id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,completed: false,text: action.payload.text,},];const deleteTodo = (state, action) =>state.filter((todo) => todo.id !== action.payload.id);const editTodo = (state, action) =>state.map((todo) =>todo.id === action.payload.id? { ...todo, text: action.payload.text }: todo,);const completeTodo = (state, action) =>state.map((todo) =>todo.id === action.payload.id? { ...todo, completed: !todo.completed }: todo,);const completeAllTodos = (state) => {const areAllMarked = state.every((todo) => todo.completed);return state.map((todo) => ({...todo,completed: !areAllMarked,}));};const clearCompleted = (state) =>state.filter((todo) => todo.completed === false);
Now combine them in a slice:
// ducks/todos.jsconst todos = createSlice({slice: "todos",initialState,reducers: {add: addTodo,delete: deleteTodo,edit: editTodo,complete: completeTodo,completeAll: completeAllTodos,clearCompleted: clearCompleted,},});
By default, createSlice selectors simply return state values (e.g.,
todos.selectors.getTodos). This application needs more complex selectors.
For example, getVisibleTodos needs both the visibility filter and todos.
createSelector takes an array of selectors (or state paths) as its first
parameter and a function implementing the selection logic as its second.
// ducks/todos.jsconst { getVisibilityFilter } = visibilityFilter.selectors;todos.selectors.getVisibleTodos = createSelector([getVisibilityFilter, todos.selectors.getTodos],(visibilityFilter, todos) => {switch (visibilityFilter) {case SHOW_ALL:return todos;case SHOW_COMPLETED:return todos.filter((t) => t.completed);case SHOW_ACTIVE:return todos.filter((t) => !t.completed);default:throw new Error("Unknown filter: " + visibilityFilter);}},);todos.selectors.getCompletedTodoCount = createSelector([todos.selectors.getTodos],(todos) =>todos.reduce((count, todo) => (todo.completed ? count + 1 : count), 0),);
I added the new selectors to the todos.selectors object, keeping all selectors
in one place.
Create Store
The library also provides configureStore and getDefaultMiddleware.
configureStore wraps Redux's createStore. It offers the same functionality
with a cleaner API--enabling developer tools requires just a boolean.
getDefaultMiddleware returns
[immutableStateInvariant, thunk, serializableStateInvariant] in development
and [thunk] in production.
redux-immutable-state-invariant: Detects mutations in reducers during dispatch and between dispatches (in selectors or components).serializable-state-invariant-middleware: Checks state and actions for non-serializable values like functions and Promises.
// store.jsimport { configureStore, getDefaultMiddleware } from "redux-starter-kit";import { combineReducers } from "redux";import { visibilityFilter, todos } from "./ducks";const preloadedState = {todos: [{text: "Use Redux",completed: false,id: 0,},],};const reducer = combineReducers({todos: todos.reducer,visibilityFilter: visibilityFilter.reducer,});const middleware = [...getDefaultMiddleware()];export const store = configureStore({reducer,middleware,devTools: process.env.NODE_ENV !== "production",preloadedState,});
Final thoughts
Redux Starter Kit reduces boilerplate, making code cleaner and easier to understand. It also speeds up development.
Source Code: https://github.com/magarcia/todomvc-redux-starter-kit