React Redux
This tutorial explores React-Redux in practice by building a step by step example of a todo list app. If you need are new to Redux start here.
The Setup
Let's first setup our environment by using Create React App:
npx create-react-app todoapp
cd todoapp
Redux has no relationship with React. The React-Redux library is designed to work with React's component model. It uses wrapper components that manage the Redux unidirectional data flow for us.
React Redux 6.x requires React 16.4 or later.
Install Redux and React-Redux:
npm install redux react-redux
Open the todoapp
in your IDE. <br>
Inside the ./src
directory delete all the files to have a clean slate.
Create ./components/App.js
and our app entry point ./src/index.js
.
todoapp |- public |- /src + |- index.js + |- components + |- App.js |- .gitignore |- package.json |- package-lock.json |- README.md |- yarn.lock
App.js
import React from "react" const App = () => { return <div>"Entry Point"</div> } export default App
index.js
import React from "react" import ReactDOM from "react-dom" import App from "./components/App" ReactDOM.render(<App />, document.querySelector("#root"))
Material UI
We're going to use Material Design to style our components.<br> Install Material UI:
npm install @material-ui/core
Load the Roboto font and Material Icons on ./public/index.html
.
<head> <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" /> <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" /> </head>
Install SVG icons:
npm install @material-ui/icons
Start the server npm run start
and we're ready to rock & roll!! :metal:
First, we're going to write our Redux logic. Then we'll bring React into the picture. Stop the server Ctrl+C
for now.
Actions
In Redux, we can only change the state
by emitting actions. Actions are plain JS objects that describe the change. Action Creators are functions that simply return the action
.
Create our todoActions and a constants files to keep all the actions in one place and avoid typos.
todoapp |- /src + |- /redux + |- actions + |- todoActions.js //action creators + |- constants + |- constants.js //action type constants
On our todoapp we'll add functionality for adding, deleting, updating, and filtering tasks. Let's add these actions to our constants file:
constants.js
export const ADD_TODO = "ADD_TODO" export const UPDATE_TODO = "UPDATE_TODO" export const DELETE_TODO = "DELETE_TODO" export const TOGGLE_DONE_TODO = "TOGGLE_DONE_TODO" export const SET_FILTER_TODOS = "SET_FILTER_TODOS" export const SHOW_ALL_TODOS = "SHOW_ALL" export const SHOW_ACTIVE_TODOS = "SHOW_ACTIVE" export const SHOW_COMPLETED_TODOS = "SHOW_COMPLETED"
The only requirement in the action
object is that it must have a type
property, this will be use by the reducers to identify the dispatched action. The rest of the action
object structure is up to the needs of our operation.
Let's make our action creators
functions and export them.
todoActions.js
//import action types constants import { ADD_TODO, UPDATE_TODO, DELETE_TODO, TOGGLE_DONE_TODO, SET_FILTER_TODOS, } from "../constants/constants" //addTodo action creator let nextTodoId = 0 export const addTodo = content => { return { type: ADD_TODO, payload: { content, id: nextTodoId++, }, } } //updateTodo action creator export const updateTodo = (id, content) => { return { type: UPDATE_TODO, payload: { id, content, }, } } //deleteTodo action creator export const deleteTodo = id => { return { type: DELETE_TODO, payload: { id, }, } } //completedTodo action creator export const toggleDoneTodo = id => { return { type: TOGGLE_DONE_TODO, payload: { id, }, } } //filterTodos export const filterTodos = filter => { return { type: SET_FILTER_TODOS, filter, } }
Reducer
Reducers are pure functions that manage how the app's state changes in response to actions
. We use multiple reducers to handle different parts of the app's state logic. If we have more than one Reducer, we need to combine them all into one using the combineReducers function.
The reducers functions need a default value to avoid any undefined errors when they run for the first time. We'll create a separate file to keep our state default values in one place.
Let's create the reducers and initial state files for our todoapp.
todoapp |- /src |- /redux ... + |- /reducers + |- index.js //root reducer(combined reducers) + |- todoFilterReducer.js + |- todoReducer.js + |- /store + |- initialState.js //initial state
initialState.js
import { SHOW_ALL_TODOS } from "../constants/constants" export default { todos: [], todosFilter: SHOW_ALL_TODOS, }
Reducers always receive two arguments in the same order: the previous state
and an action
. After the reducers do their operation they must return the next state
or the previous state
if nothing has changed. Never mutate the state.
todoReducer.js
// constant types import { ADD_TODO, UPDATE_TODO, DELETE_TODO, TOGGLE_DONE_TODO, } from "../constants/constants" // initial state import initalState from "../store/initialState" // todoReducer export default function (state = initalState.todos, action) { // deconstructing action object const { payload } = action // checking action type switch (action.type) { // add_todo reducer case ADD_TODO: return [ ...state, { id: payload.id, completed: false, content: payload.content, }, ] // update_todo reducer case UPDATE_TODO: return state.map(todo => { return todo.id === action.payload.id ? { ...todo, content: payload.content } : todo }) // delete_todo reducer case DELETE_TODO: return state.filter(todo => todo.id !== payload.id) // toggle_todo reducer case TOGGLE_DONE_TODO: return state.map(todo => { return todo.id === payload.id ? { ...todo, completed: !todo.completed } : todo }) // default state default: return state } }
todoFilterReducer.js
import { SET_FILTER_TODOS } from "../constants/constants" import initalState from "../store/initialState" const todoFilterReducer = (state = initalState.todosFilter, action) => { switch (action.type) { case SET_FILTER_TODOS: return action.filter default: return state } } export default todoFilterReducer
Root Reducer
We can make a single reducer
with the combineReducers
function. This function expects an object with all our imported reducers.
index.js (root reducer)
import { combineReducers } from "redux" import todosReducer from "./todoReducer" import todosFilterReducer from "./todoFilterReducer" /* This key names will used in our React components to access the state */ const rootReducer = combineReducers({ todos: todosReducer, todosFilter: todosFilterReducer, }) export default rootReducer
Store
The store
holds the entire state tree of our app. We can only have one store in our entire app. Let's create a file for our store
.
todoapp |- /src |- /redux ... |- /store |- initialState.js + |- index.js //store
We create a store
with the createStore function and pass our rootReducer.
.src/redux/store/index.js (store)
import { createStore } from "redux" import rootReducer from "../reducers" export default () => { return createStore(rootReducer) }
We can pass two additional arguments to the createStore
function.
1 - PreloadState (initial state)<br>
For our todoapp We will pass the object we created on initialState.js
. On universal apps we may optionally specify to hydrate the state from the server.
2 - Enhancer <br> The Enhancer is used to add third-party capabilities to the store, such as: middleware, time travel, persistence, etc. The only enhancer that ships with Redux is applyMiddleWare. Let's add Redux DevTools to help us debug our state changes. View Docs for API reference.
npm install --save-dev redux-devtools-extension
.src/redux/store/index.js (store)
import { createStore } from "redux" import rootReducer from "../reducers" import initialState from "./initialState" import { composeWithDevTools } from "redux-devtools-extension" export default () => { return createStore(rootReducer, initialState, composeWithDevTools()) }
PROVIDER
The <Provider />
component makes the Redux store
accessible in our React components. The Provider
accepts our store
via props
.
We created our store
in it's own file. This pattern will keep our code more organize once our configuration gets more complex.
index.js (entry point)
import React from "react" import ReactDOM from "react-dom" import { Provider } from "react-redux" import App from "./components/App" import configureStore from "./redux/store" const store = configureStore() ReactDOM.render( <Provider store={store}> <App /> </Provider>, document.getElementById("root") )
This completes the Redux boiler plate of our application.
REACT
React Redux is designed to work with React's component model.
Our container components will subscribe to our Redux Store and dispatch the actions. Our presentational components will read data and invoke callbacks from props.
Let's add the files for our todoapp React components:
todoapp |- /src |- components |- App.js + |- /Todos + |- addTodos.js + |- filterTodos.js + |- todoEmptyState.js + |- todoItem.js + |- todos.js ...
Connect
To subscribe our React container components to Redux, we use the connect()
function provided by React Redux.
import { connect } from "react-redux"
The connect
function can take two function arguments: mapStateToProps
and mapDispatchToProps
.
//no second argument connect(mapStateToProps)(MyComponent) //or no first argument connect(null, mapDispatchToProps)(MyComponent)
mapStateToProps
The mapStateToProps function is used for taking relevant data from the store
and making it available in our components and we can pass two arguments.
1. state <br>
The first argument will return the entire Redux store state with the keys
that we defined on combineReducers
(root reducer).
2. ownProps (optional) <br>
The second argument is called ownProps
(optional). This is used if our component needs data from its own props to retrieve data from the store.
The mapStateToProps
function should return a plain object containing the data needed for our component.
example:
function mapStateToProps(state) { const { todos } = state return { todos, } }
mapDispatchToProps
mapDispatchToProps
is the second function argument we can pass to connect
. It's used for dispatching actions to the store.
The are a couple of approaches for dispatching. We're going to use BindingActionCreators function to invoke our action creators directly. View other options.
example:
import { connect } from "react-redux" import { bindActionCreators } from "redux" //action creators import { updateTodo, deleteTodo, toggleDoneTodo, } from "../../redux/actions/todoActions" //return action creators function mapDispatchToProps(dispatch) { return bindActionCreators( { deleteTodo, updateTodo, toggleDoneTodo, }, dispatch ) }
Exporting our component
export default connect(mapStateToProps, mapDispatchToProps)(Todos)
Next, let's implement connect to our React components.
Todos container
This container component will be dispatching actions from the callbacks of the todoItem
component. On the mapStateToProps
we are destructuring the state and using setFilterTodos
to filter the list of todos that we iterate and render.
todos.js
import React, { Component } from "react" import { connect } from "react-redux" import { bindActionCreators } from "redux" //constants import { SHOW_ALL_TODOS, SHOW_COMPLETED_TODOS, SHOW_ACTIVE_TODOS, } from "../../redux/constants/constants" //action creators import { updateTodo, deleteTodo, toggleDoneTodo, } from "../../redux/actions/todoActions" //material-ui components import List from "@material-ui/core/List" import Divider from "@material-ui/core/Divider" //our components import TodoItem from "./todoItem" import TodoEmptyState from "./todoEmptyState" class Todos extends Component { //render TodoItems renderTodos() { const { todos, deleteTodo, updateTodo, toggleDoneTodo, todosFilter } = this.props const renderTodoItem = todos.map(todo => { return ( <TodoItem key={todo.id} todoItem={todo} onItemDelete={id => deleteTodo(id)} onUpdateTodo={(id, content) => updateTodo(id, content)} onItemToggleComplete={id => toggleDoneTodo(id)} /> ) }) return ( <List> {todos.length > 0 && <Divider />} {!todos.length && <TodoEmptyState activeFilter={todosFilter} />} {renderTodoItem} </List> ) } render() { return this.renderTodos() } } //filter todos const setFilterTodos = (todos, filter) => { switch (filter) { case SHOW_ALL_TODOS: return todos case SHOW_COMPLETED_TODOS: return todos.filter(t => t.completed) case SHOW_ACTIVE_TODOS: return todos.filter(t => !t.completed) default: return todos } } //return action creators function mapDispatchToProps(dispatch) { return bindActionCreators( { deleteTodo, updateTodo, toggleDoneTodo, }, dispatch ) } //return data our component needs function mapStateToProps({ todos, todosFilter }) { return { todos: setFilterTodos(todos, todosFilter), todosFilter, } } //export TodoComponent export default connect(mapStateToProps, mapDispatchToProps)(Todos)
Todo item
The todoItem.js
is a presentational component and we're passing our actions
and store
via props. This component is 'unaware' of Redux. Its only responsibility is to render the UI and trigger callbacks to the container component.
todoItem.js
import React, { Fragment } from "react" //material-ui components import ListItem from "@material-ui/core/ListItem" import ListItemSecondaryAction from "@material-ui/core/ListItemSecondaryAction" import ListItemText from "@material-ui/core/ListItemText" import IconButton from "@material-ui/core/IconButton" import DeleteOutline from "@material-ui/icons/DeleteOutline" import Checkbox from "@material-ui/core/Checkbox" import Divider from "@material-ui/core/Divider" import Tooltip from "@material-ui/core/Tooltip" import InputBase from "@material-ui/core/InputBase" //receive Redux state via props, we invoke actions via props const TodoItem = ({ todoItem, onItemDelete, onUpdateTodo, onItemToggleComplete, }) => { const handleTextChange = event => { onUpdateTodo(todoItem.id, event.target.value) } return ( <Fragment> <ListItem> <Tooltip title="Completed" placement="right"> <Checkbox checked={todoItem.completed} onChange={() => onItemToggleComplete(todoItem.id)} /> </Tooltip> <ListItemText primary={ <InputBase multiline={true} value={todoItem.content} onChange={event => handleTextChange(event)} /> } /> <ListItemSecondaryAction onClick={() => onItemDelete(todoItem.id)}> <Tooltip title="Delete" placement="right"> <IconButton aria-label="delete todo"> <DeleteOutline /> </IconButton> </Tooltip> </ListItemSecondaryAction> </ListItem> <Divider /> </Fragment> ) } export default TodoItem
Add a todo
Let's make the addTodo a container component and dispatch actions directly without having to use mapDispatchToProps
. We do need the connect
function.
addTodo.js
import React from "react" import { connect } from "react-redux" //action creator import { addTodo } from "../../redux/actions/todoActions" //material-ui components import Button from "@material-ui/core/Button" import TextField from "@material-ui/core/TextField" //passing dispatch directly const AddTodo = ({ dispatch }) => { let input return ( <form autoComplete="off" onSubmit={e => { e.preventDefault() if (!input.value.trim()) { return } dispatch(addTodo(input.value)) input.value = "" input.style.height = "auto" }} > <TextField id="addTask" inputRef={node => (input = node)} placeholder="Empty task" fullWidth margin="normal" variant="outlined" autoFocus={true} multiline={true} spellCheck="false" InputLabelProps={{ shrink: true, }} /> <Button style={{ float: "right", marginTop: "8px" }} variant="contained" size="medium" color="primary" type="submit" > Add Task </Button> </form> ) } export default connect()(AddTodo)
Filter todos
The filter is a container component. We use connect
to access our store and dispatch the filter changes.
filterTodos.js
import React from "react" import { connect } from "react-redux" import { bindActionCreators } from "redux" //constants import { SHOW_ALL_TODOS, SHOW_COMPLETED_TODOS, SHOW_ACTIVE_TODOS, } from "../../redux/constants/constants" //action creator import { filterTodos } from "../../redux/actions/todoActions" //material-ui components import Button from "@material-ui/core/Button" import Menu from "@material-ui/core/Menu" import MenuItem from "@material-ui/core/MenuItem" const FilterTodos = props => { //material-ui menu const [anchorEl, setAnchorEl] = React.useState(null) const open = Boolean(anchorEl) //material-ui event handler function handleClick(event) { setAnchorEl(event.currentTarget) } //material-ui close menu function handleClose() { setAnchorEl(null) } //dispatch filter action and close menu function handleChange(state) { props.filterTodos(state) handleClose() } return ( <div> <Button aria-owns={open ? "fade-menu" : undefined} aria-haspopup="true" size="small" onClick={handleClick} > {props.todosFilter.split("_").join(" ")} </Button> <Menu id="fade-menu" anchorEl={anchorEl} open={open} onClose={handleClose} > <MenuItem onClick={() => handleChange(SHOW_ALL_TODOS)}>All</MenuItem> <MenuItem onClick={() => handleChange(SHOW_ACTIVE_TODOS)}> Active </MenuItem> <MenuItem onClick={() => handleChange(SHOW_COMPLETED_TODOS)}> Completed </MenuItem> </Menu> </div> ) } //return action creators function mapDispatchToProps(dispatch) { return bindActionCreators( { filterTodos, }, dispatch ) } //return data our component needs function mapStateToProps({ todosFilter }) { return { todosFilter, } } export default connect(mapStateToProps, mapDispatchToProps)(FilterTodos)
Empty state
This is a presentational component. We imported it in todos.js
and pass the active filter to render the correct message.
todoEmptyState.js
import React, { Fragment } from "react" import { SHOW_ALL_TODOS, SHOW_COMPLETED_TODOS, SHOW_ACTIVE_TODOS, } from "../../redux/constants/constants" const styles = { container: { display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "150px", color: "#313777", fontFamily: "Montserrat, sans-serif", }, title: { fontSize: "24px", fontWeight: "bold", margin: "15px 0 5px", }, subtitle: { fontSize: "16px", margin: "5px 0", }, } //receive redux activeFilter and show correct message const TodoEmptyState = ({ activeFilter }) => { const renderMessage = () => { switch (activeFilter) { case SHOW_ALL_TODOS: return ( <Fragment> <h2 style={styles.title}>All clear</h2> <p style={styles.subtitle}>Looks like everything's done.</p> </Fragment> ) case SHOW_COMPLETED_TODOS: return ( <Fragment> <h2 style={styles.title}>Been busy?</h2> <p style={styles.subtitle}> You haven't completed any tasks recently. </p> </Fragment> ) case SHOW_ACTIVE_TODOS: return ( <Fragment> <h2 style={styles.title}>Cheers!</h2> <p style={styles.subtitle}> Looks like you're caught up with your tasks. </p> </Fragment> ) } } return <span style={styles.container}>{renderMessage()}</span> } export default TodoEmptyState
Main view
Finally, let's add our container components to App.js
.
App.js
import React from "react" import Todos from "./Todos/todos" import AddTodo from "./Todos/addTodo" import FilterTodos from "./Todos/filterTodos" //material-ui grid import Grid from "@material-ui/core/Grid" const styles = { container: { maxWidth: 500, margin: `50px auto`, }, } const App = () => { return ( <div style={styles.container}> <Grid container spacing={24}> <Grid item xs={12}> <FilterTodos /> <AddTodo /> </Grid> <Grid item xs={12}> <Todos /> </Grid> </Grid> </div> ) } export default App
Hey, I'm Ignacio Villamar
Senior Frontend Engineer, living in the NYC metro area.
Follow @ivstudio