Taming Large React Applications w/ Redux
About the Speaker
-
Senior UI Developer at PureCars, a digital marketing and automation company in the automotive space
-
Moonlights as a consultant with his brother where they work with small businesses to create modern dashboard applications.
-
Love ultimate frisbee and watching NFL
Joel Kanzelmeyer
React
React Primer
- Simple
- Declarative
- Composable components
Thinking in React: Components
Redux
Flux Architecture Primer
Traditional MVC
Controller
Model
Model
Model
Model
Model
View
View
View
View
View
Flux Architecture Primer
Flux
Dispatcher
Store
View
Action
Action
What is Redux?
"A predictable state container for JavaScript apps."
Single store for all application state
Never modify state directly
"Actions" are dispatched and "reducers" know how state should be changed for each action
Popular implementation of the Flux architecture
Why Redux Helps Applications Scale Well
Easily Testable
Easily Testable
// reducer.js
export function reducer(state, action) {
switch (action.type) {
case ADD_TODO:
return Object.assign({}, state, {
todos: [
...state.todos,
{
text: action.text,
completed: false
}
]
});
default:
return state;
}
}
// reducer-spec.js
import { reducer } from './reducer';
const state = {
todos: []
};
const action = {
type: 'ADD_TODO',
text: 'Write unit tests'
};
describe('todos reducer', () => {
it('should handle the "ADD_TODO" action', () => {
const newState = reducer(state, action);
expect(newState.todos[0].text).toEqual(action.text);
expect(newState.todos[0].completed).toBeFalsy();
})
})
Easy to reason about
(RIP Gene Wilder)
Good separation of concerns
Your business logic is kept out of your views
Developer adoption
A community of great developers support Redux
Middleware
Enhance base functionality with custom middleware
React Boilerplate
The struggle is real
JS Tool Fatigue
React Boilerplate to the rescue!
- Most stable/production-ready solution I've found
- Follows good practices for large React apps
- Server-side rendering
- Code splitting (lazy load code when needed)
- Hot module reloading (excellent for debugging)
- ES6 support and CSS modules/PostCSS
React Boilerplate to the rescue!
Best Practices
Redux at Scale
Best Practice #1
Smart containers and dumb components
Redux at Scale
Dumb Components
(Presentational)
Dumb Component Example
const DumbComponent = (props) => {
const { text, onChange } = props;
return (
<div>
<p>Your text: {text}</p>
<input value={text} onChange={onChange} />
</div>
);
};
Smart Containers
(Stateful)
Smart Container Example
//...
import { updateText } from '../actions';
//...
class SmartContainer extends React.Component {
render() {
const { text, updateText } = this.props;
return (
<DumbComponent text={text} onChange={updateText} />
);
}
}
const mapStateToProps = (state) => ({
text: state.text
});
const mapDispatchToProps = (dispatch) => ({
updateText: (evt) => dispatch(updateText(evt.target.text))
});
export default connect(mapStateToProps, mapDispatchToProps)(SmartContainer);
Benefits
- Better separation of concerns
- Better reusability
- A library of UI components
Best Practice #2
Immutability
Redux at Scale
Equality checking in Javascript
1 === 1
'string' === 'string'
true === true
const obj1 = { prop: ’someValue’ };
const obj2 = { prop: ’someValue’ };
console.log(obj1 === obj2); // false
Why is this a problem?
Fine-tuning React Performance
shouldComponentUpdate
class NewsFeed extends React.Component {
shouldComponentUpdate(nextProps) {
return !_.isEqual(this.props.articles, nextProps.articles) ||
!_.isEqual(this.props.user, nextProps.user);
}
render() {
const { user, articles } = this.props;
return (
<div>
<p>Hello, {user.firstName}! Here is your news for the day:</p>
<div>
{
articles.map((article) => (
<Article article=[article} />
))
}
</div>
</div>
);
}
}
Immutable.js
shouldComponentUpdate with Immutable.js
shouldComponentUpdate(nextProps) {
return this.props.articles !== nextProps.articles ||
this.props.user !== nextProps.user;
}
shouldComponentUpdate(nextProps, nextState) {
return shallowCompare(this, nextProps, nextState);
}
react-addons-shallow-compare
Benefits
- Improves performance
- Simplifies shouldComponentUpdate
- Easier to reason about
Best Practice #3
Normalized state tree
Redux at Scale
API returns deeply nested objects
// API response
[
{
"id": 1,
"title": "Why React is Awesome",
"author": {
"id": 1,
"name": "Joel Kanzelmeyer"
}
},
{
"id": 2,
"title": "Why Redux is Great",
"author": {
"id": 1,
"name": "Joel Kanzelmeyer"
}
}
]
// Redux state
{
articles: [{
id: 1,
title: 'Why React is Awesome',
author: {
id: 1,
name: 'Joel Kanzelmeyer'
}
}, {
id: 2,
title: 'Why Redux is Great',
author: {
id: 1,
name: 'Joel Kanzelmeyer'
}
}]
}
// Redux state
{
articles: [{
id: 1,
title: 'Why React is Awesome',
author: {
id: 1,
name: 'Joel Kanzelmeyer'
}
}, {
id: 2,
title: 'Why Redux is Great',
author: {
id: 1,
name: 'Joel Cancelmeyer'
}
}]
}
normalizr
Turns this...
// API response
[
{
"id": 1,
"title": "Why React is Awesome",
"author": {
"id": 1,
"name": "Joel Kanzelmeyer"
}
},
{
"id": 2,
"title": "Why Redux is Great",
"author": {
"id": 1,
"name": "Joel Kanzelmeyer"
}
}
]
// Normalized result
{
entities: {
authors: {
1: {
id: 1,
name: 'Joel Kanzelmeyer'
}
},
articles: {
1: {
id: 1,
name: 'Why React is Awesome'
},
2: {
id: 2,
name: 'Why Redux is Great'
}
}
},
result: {
authors: [1],
articles: [1, 2]
}
}
into this.
Example with normalizr
// Redux state
{
articles: [{
id: 1,
title: 'Why React is Awesome',
author: {
id: 1,
name: 'Joel Kanzelmeyer'
}
}, {
id: 2,
title: 'Why Redux is Great',
author: {
id: 1,
name: 'Joel Kanzelmeyer'
}
}]
}
// Redux state with normalizr
{
authors: {
byId: {
1: {
id: 1,
name: 'Joel Kanzelmeyer'
}
},
all: [1]
},
articles: {
byId: {
1: {
id: 1,
title: 'Why React is Awesome',
author: 1
},
2: {
id: 2,
title: 'Why Redux is Great',
author: 1
}
},
all: [1, 2]
}
}
redux-normalizr-middleware
Action/reducer example
import { Schema, arrayOf } from 'normalizr';
// define your schemas
const article = new Schema('articles');
const author = new Schema('authors');
// define nesting rules
article.define({
author: author
});
export const getArticlesSuccess = (response) => ({
type: 'GET_ARTICLES_SUCCESS',
payload: response.body,
meta: {
// provide middleware with normalizr schema
schema: arrayOf(article)
}
});
// API response
[
{
"id": 1,
"title": "Why React is Awesome",
"author": {
"id": 1,
"name": "Joel Kanzelmeyer"
}
},
{
"id": 2,
"title": "Why Redux is Great",
"author": {
"id": 1,
"name": "Joel Kanzelmeyer"
}
}
]
// define initial state for reducers
const initialState = Immutable.Map({
byId: Immutable.Map(),
all: Immutable.Set()
});
// define default reducer that will merge in normalized state
const defaultReducer = (schemaName, state, action) => {
if (action.entities && action.entities[schemaName]) {
return state
.mergeIn(['byId'], action.entities[schemaName])
.mergeIn(['all'], action.result[schemaName]);
}
return state;
}
// authors reducer
const authors = (state = initialState, action) => {
switch (action.type) {
case 'UPDATE_AUTHOR':
return state.mergeIn(['byId', action.id], action.author);
default:
return defaultReducer('authors', state, action);
}
};
// articles reducer
const articles = (state = initialState, action) => {
return defaultReducer('articles', state, action);
};
// root reducer
const reducer = combineReducers({
authors,
articles
});
Benefits
- Normalizes deeply nested objects
- Prevents duplicate state
- Normalization can happen in middleware
Problems You'll Encounter
Redux at Scale
(and solutions to them)
Problem #1
Asynchronous/chained actions
(actions with side-effects)
Redux at Scale
Example
// API service
const getBooks = () =>
fetch('/api/books')
.then(handleErrors)
.then(parseJSON);
// View
vm.loadBooks = () => {
vm.booksLoading = true;
booksApi.getBooks()
.then((books) => {
vm.booksLoading = false;
vm.books = books;
})
.catch((err) => {
vm.booksLoading = false;
vm.error = err;
});
};
MVC
// Actions
const getBooks = () => {
return fetch('/api/books')
.then(handleErrors)
.then(parseJSON)
.then((books) => ({
type: 'GET_BOOKS',
payload: books
});
};
onComponentWillMount() {
this.props.getBooks();
// Uncaught Error: Actions must be plain objects.
// Use custom middleware for async actions.
}
React/Redux
What We Really Need
// Actions
const getBooks = () => ({
type: 'GET_BOOKS'
});
const getBooksSuccess = (books) => ({
type: 'GET_BOOKS_SUCCESS',
payload: books
});
const getBooksFailure = (error) => ({
type: 'GET_BOOKS_FAILURE',
payload: error
});
// Reducer
const booksReducer = (state, action) => {
switch (action.type) {
case 'GET_BOOKS':
return {
...state,
loading: true,
error: null
};
case 'GET_BOOKS_SUCCESS':
return {
...state,
loading: false,
books: action.payload
};
case 'GET_BOOKS_FAILURE':
return {
...state,
loading: false,
error: action.payload
};
default:
return state;
}
};
redux-thunk
Solution #1
Thunked Action
function incrementIfOdd() {
return (dispatch, getState) => {
const { counter } = getState();
if (counter % 2 === 0) {
return;
}
dispatch(increment());
};
}
Example with redux-thunk
// Actions
// create a thunked action
const getBooks = () => {
return (dispatch, getState) => {
// check to see if books have already been fetched
if (!getState().books) {
// if not, dispatch the GET_BOOKS action
dispatch({
type: 'GET_BOOKS'
});
// then you make your http request
booksApi.getBooks()
.then((books) => {
// dispatch success action
dispatch(getBooksSuccess(books));
})
.catch((error) => {
// dispatch failure action
dispatch(getBooksFailure(error));
});
}
};
};
const getBooksSuccess = (books) => ({
type: 'GET_BOOKS_SUCCESS',
payload: books
});
const getBooksFailure = (error) => ({
type: 'GET_BOOKS_FAILURE',
payload: error
});
// in your component
onComponentWillMount() {
this.props.getBooks();
/**
dispatches GET_BOOKS and
then when response comes back
it will dispatch either
GET_BOOKS_SUCCESS or
GET_BOOKS_FAILURE
**/
}
render() {
const {
isLoading,
error,
books
} = this.props;
if (isLoading) {
return (
<div>loading...</div>
);
}
if (error) {
return (
<div>error: {error}</div>
);
}
return (
<BooksList books={books} />
);
}
redux-thunk
- Handle side-effects within your action creators
- Able to unit test, must mock redux store and http requests
redux-saga
Solution #2
(My preference, for what it's worth)
redux-saga
class UserComponent extends React.Component {
...
onSomeButtonClicked() {
const { userId, dispatch } = this.props
dispatch({type: 'USER_FETCH_REQUESTED', payload: {userId}})
}
...
}
import { takeEvery } from 'redux-saga'
import { call, put } from 'redux-saga/effects'
import Api from '...'
// worker Saga: will be fired on USER_FETCH_REQUESTED actions
function* fetchUser(action) {
try {
const user = yield call(Api.fetchUser, action.payload.userId);
yield put({type: 'USER_FETCH_SUCCEEDED', user: user});
} catch (e) {
yield put({type: 'USER_FETCH_FAILED', message: e.message});
}
}
/*
Starts fetchUser on each dispatched 'USER_FETCH_REQUESTED' action.
Allows concurrent fetches of user.
*/
function* mySaga() {
yield* takeEvery('USER_FETCH_REQUESTED', fetchUser);
}
export default mySaga;
Why I prefer redux-saga over redux-thunk
Easier to reason about
import { takeEvery } from 'redux-saga'
import { call, put, select } from 'redux-saga/effects'
import { getBooksSuccess, getBooksFailure } from './actions';
const selectBooks = (state) => state.books;
function* fetchBooks(action) {
// check to see if books exist in state already
const books = yield select(selectBooks);
if (!books) {
try {
// call booksApi.getBooks to get books from API
const _books = yield call(booksApi.getBooks);
// dispatch GET_BOOKS_SUCCESS
yield put(getBooksSuccess(_books));
} catch (e) {
// if request fails, dispatch GET_BOOKS_FAILURE
yield put(getBooksFailure(e.message));
}
}
}
// "listener" function that will listen for GET_BOOKS actions
function* booksSaga() {
yield* takeEvery('GET_BOOKS', fetchBooks);
}
export default booksSaga;
// Actions
// create a thunked action
const getBooks = () => ({
type: 'GET_BOOKS'
});
const getBooksSuccess = (books) => ({
type: 'GET_BOOKS_SUCCESS',
payload: books
});
const getBooksFailure = (error) => ({
type: 'GET_BOOKS_FAILURE',
payload: error
});
Why I prefer redux-saga over redux-thunk
Testing is simpler
import test from 'tape';
import { put, call } from 'redux-saga/effects'
import { delay } from 'redux-saga'
import { incrementAsync } from './sagas'
test('incrementAsync Saga test', (assert) => {
const gen = incrementAsync()
assert.deepEqual(
gen.next().value,
call(delay, 1000),
'incrementAsync Saga must call delay(1000)'
)
assert.deepEqual(
gen.next().value,
put({type: 'INCREMENT'}),
'incrementAsync Saga must dispatch an INCREMENT action'
)
assert.deepEqual(
gen.next(),
{ done: true, value: undefined },
'incrementAsync Saga must be done'
)
assert.end()
});
import { put, call } from 'redux-saga/effects'
import { delay } from 'redux-saga'
export function* incrementAsync() {
// use the call Effect
yield call(delay, 1000)
yield put({ type: 'INCREMENT' })
}
Why I prefer redux-saga over redux-thunk
Highly de-coupled
- Your actions no longer contain business logic
- Business logic can be encapsulated in sagas
- Add new logic to existing actions by creating sagas
redux-saga
- Keeps your actions pure
- Handle side-effects in a "separate thread" with sagas
- Able to unit test without mocking surrounding environment
Problem #2
Selecting state, especially derived state, is painful and non-performant
Redux at Scale
Example
// SomeComponent.js
...
const mapStateToProps = (state) => {
const books = state.books; // Immutable map
const filterText = state.filterText;
return {
filteredBooks: books.filter((book) => {
return book.name.includes(filterText);
})
};
};
...
export default connect(mapStateToProps)(SomeComponent);
Why is this a problem?
const mapStateToProps = (state) => {
const books = state.books; // Immutable map
const filterText = state.filterText;
return {
filteredBooks: books.filter((book) => {
return book.name.includes(filterText);
})
};
};
- mapStateToProps is called on every state change
- Shallowly compares and re-renders only when changed
- Filtering, sorting, mapping, etc always returns new instance
reselect
Solution
reselect
// selectors.js
import { createSelector } from 'reselect';
const booksSelector = (state) => state.books;
const filterTextSelector = (state) => state.filterText;
export const filteredBooksSelector = createSelector(
booksSelector,
filterTextSelector,
(books, filterText) =>
books.filter((book) => {
return book.name.includes(filterText);
})
);
// SomeComponent.js
import { filteredBooksSelector } from './selectors.js';
...
const mapStateToProps = (state) => {
return {
filteredBooks: filteredBooksSelector(state)
};
};
...
export default connect(mapStateToProps)(SomeComponent);
reselect
- Better performance via memoized selectors
- Selectors are now composable
- Added benefit of encapsulating state selection logic
Problem #3
Too much boilerplate code and too reliant on string constants
Redux at Scale
export const ADD_TODO = 'ADD_TODO';
export const REMOVE_TODO = 'REMOVE_TODO';
export const EDIT_TODO = 'EDIT_TODO';
export const COMPLETE_TODO = 'COMPLETE_TODO';
export const COMPLETE_ALL = 'COMPLETE_ALL';
export const CLEAR_COMPLETED = 'CLEAR_COMPLETED';
import * as types from './constants';
const addTodo = (text) => ({
type: types.ADD_TODO,
payload: { text }
});
const removeTodo = (id) => ({
type: types.REMOVE_TODO,
payload: { id }
});
const editTodo = (id, text) => ({
type: types.EDIT_TODO,
payload: { id, text }
});
const completeTodo = (id) => ({
type: types.COMPLETE_TODO,
payload: { id }
});
const completeAll = () => ({
type: types.COMPLETE_ALL
});
const clearCompleted = () => ({
type: types.CLEAR_COMPLETED
});
import {
ADD_TODO, DELETE_TODO, EDIT_TODO, COMPLETE_TODO,
COMPLETE_ALL, CLEAR_COMPLETED
} from './constants';
const initialState = [
{
text: 'Use Redux',
completed: false,
id: 0
}
]
export default function todos(state = initialState, action) {
switch (action.type) {
case ADD_TODO:
return [
{
id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
completed: false,
text: action.text
},
...state
]
case DELETE_TODO:
return state.filter(todo =>
todo.id !== action.id
)
case EDIT_TODO:
return state.map(todo =>
todo.id === action.id ?
{ ...todo, text: action.text } :
todo
)
case COMPLETE_TODO:
return state.map(todo =>
todo.id === action.id ?
{ ...todo, completed: !todo.completed } :
todo
)
case COMPLETE_ALL:
const areAllMarked = state.every(todo => todo.completed)
return state.map(todo => ({
...todo,
completed: !areAllMarked
}))
case CLEAR_COMPLETED:
return state.filter(todo => todo.completed === false)
default:
return state
}
}
Constants
Actions
Reducer
Example
redux-act
Solution
createAction
Simpler, more consistent way to create redux action creators
const addTodo = createAction('Add todo');
addTodo('content');
// return {
// type: '[1] Add todo',
// payload: 'content'
// }
const editTodo = createAction(
'Edit todo',
(id, content) => ({id, content})
);
editTodo(42, 'the answer');
// return {
// type: '[2] Edit todo',
// payload: {
// id: 42,
// content: 'the answer'
// }
// }
const serializeTodo = createAction('SERIALIZE_TODO');
serializeTodo(1);
// return { type: 'SERIALIZE_TODO', payload: 1 }
createReducer
Simpler, more consistent way to create redux reducers
const increment = createAction();
const add = createAction();
// First pattern
const reducerMap = createReducer({
[increment]: (state) => state + 1,
[add]: (state, payload) => state + payload
}, 0);
// Second pattern
const reducerFactory = createReducer(function (on, off) {
on(increment, (state) => state + 1);
on(add, (state, payload) => state + payload);
// 'off' remove support for a specific action
}, 0);
import { createAction } from 'redux-act';
const addTodo = createAction('ADD_TODO');
const removeTodo = createAction('REMOVE_TODO');
const editTodo = createAction('EDIT_TODO');
const completeTodo = createAction('COMPLETE_TODO');
const completeAll = createAction('COMPLETE_ALL');
const clearCompleted = createAction('CLEAR_COMPLETED');
import {
addTodo, deleteTodo, editTodo, completeTodo,
completeAll, clearCompleted
} from './actions';
import { createReducer } from 'redux-act';
const initialState = [
{
text: 'Use Redux',
completed: false,
id: 0
}
]
export default createReducer({
[addTodo]: (state, { text }) => [
{
id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
completed: false,
text
},
...state
],
[deleteTodo]: (state, { id }) => state.filter(todo => todo.id !== id),
[editTodo]: (state, { id, text }) => state.map(todo =>
todo.id === id ?
{ ...todo, text } :
todo
),
[completeTodo]: (state, { id }) => state.map(todo =>
todo.id === id ?
{ ...todo, completed: !todo.completed } :
todo
),
[completeAll]: (state) => {
const areAllMarked = state.every(todo => todo.completed)
return state.map(todo => ({
...todo,
completed: !areAllMarked
}))
},
[clearCompleted]: (state) => state.filter(todo => todo.completed === false)
}, initialState)
Actions
Reducer
export const ADD_TODO = 'ADD_TODO';
export const REMOVE_TODO = 'REMOVE_TODO';
export const EDIT_TODO = 'EDIT_TODO';
export const COMPLETE_TODO = 'COMPLETE_TODO';
export const COMPLETE_ALL = 'COMPLETE_ALL';
export const CLEAR_COMPLETED = 'CLEAR_COMPLETED';
import * as types from './constants';
const addTodo = (text) => ({
type: types.ADD_TODO,
payload: { text }
});
const removeTodo = (id) => ({
type: types.REMOVE_TODO,
payload: { id }
});
const editTodo = (id, text) => ({
type: types.EDIT_TODO,
payload: { id, text }
});
const completeTodo = (id) => ({
type: types.COMPLETE_TODO,
payload: { id }
});
const completeAll = () => ({
type: types.COMPLETE_ALL
});
const clearCompleted = () => ({
type: types.CLEAR_COMPLETED
});
import {
ADD_TODO, DELETE_TODO, EDIT_TODO, COMPLETE_TODO,
COMPLETE_ALL, CLEAR_COMPLETED
} from './constants';
const initialState = [
{
text: 'Use Redux',
completed: false,
id: 0
}
]
export default function todos(state = initialState, action) {
switch (action.type) {
case ADD_TODO:
return [
{
id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
completed: false,
text: action.text
},
...state
]
case DELETE_TODO:
return state.filter(todo =>
todo.id !== action.id
)
case EDIT_TODO:
return state.map(todo =>
todo.id === action.id ?
{ ...todo, text: action.text } :
todo
)
case COMPLETE_TODO:
return state.map(todo =>
todo.id === action.id ?
{ ...todo, completed: !todo.completed } :
todo
)
case COMPLETE_ALL:
const areAllMarked = state.every(todo => todo.completed)
return state.map(todo => ({
...todo,
completed: !areAllMarked
}))
case CLEAR_COMPLETED:
return state.filter(todo => todo.completed === false)
default:
return state
}
}
Constants
Actions
Reducer
redux-act
- Reduce boilerplate
- Use actions themselves as references rather than constants
- Flux Standard Action compliant
Honorable Mentions
redux-actions
Conclusion
- Utilize "smart containers" and "dumb components"
- Use Immutable data to help performance
- A normalized state tree helps avoid duplication
- Conquer side-effects with redux-thunk or redux-saga
- Create performant, composable selectors with reselect
- Use redux-act to reduce boilerplate and simplify
Links
- ReactJS - facebook.github.io/react
- Redux - redux.js.org
- react-boilerplate - www.reactboilerplate.com
- Immutable.js - facebook.github.io/immutable-js
- normalizr - github.com/paularmstrong/normalizr
- redux-thunk - github.com/gaearon/redux-thunk
- redux-saga - github.com/yelouafi/redux-saga
- reselect - github.com/reactjs/reselect
- redux-act - github.com/pauldijou/redux-act
- redux-actions - github.com/acdlite/redux-actions
Joel Kanzelmeyer
@kanzelm3
Questions?
Taming Large React Applications w/ Redux
By Joel Kanzelmeyer
Taming Large React Applications w/ Redux
In this talk, Joel will introduce concepts that make large React applications more scalable and maintainable. You will learn the benefits of Redux a predictable, single-way data flow model as he walks through sample code and best practices like top down approach. You’ll walk away with what you need to know to architect a React application with all the patterns that help it scale well with your team. We will cover bootstrapping a modern React application with react-boilerplate (http://reactboilerplate.com) and using libraries like redux-saga and reselect to improve code re-use, removing coupling, and reducing complexity.
- 23,325