Taming Large React Applications w/ Redux
About the Speaker
-
Senior Developer at The Weather Company, an IBM Business
-
Works on the Mobile Web team, moonlights as contributor on the Core/Decoupling team
-
Loves watching NFL and playing ultimate frisbee
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
Key Ingredients
Redux at Scale
Key Ingredient #1
Actions/Action Creators
Redux at Scale
What is an Action?
{
type: 'ADD_TODO',
payload: 'Pick up dry cleaning'
}
An action is just an object that defines an action that was taken in your application
What is an Action Creator?
function addTodo(text) {
if (!text) {
return {
type: 'ADD_TODO',
payload: new Error('Todo text is required'),
error: true
};
}
return {
type: 'ADD_TODO',
payload: text
};
}
An action creator is a reusable function that creates an action of a given type
May contain PURE business logic
Flux Standard Action
An action MUST NOT include properties other than type, payload, error, and meta.
An action MUST
- be a plain JavaScript object.
- have a type property.
An action MAY
- have an error property.
- have a payload property.
- have a meta property.
Key Ingredient #2
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);
Another (More Advanced) Example
/**
* moduleA.connector.js
*/
import { updateText } from '../actions';
const mapStateToProps = (state) => ({
text: state.text
});
const mapDispatchToProps = (dispatch) => ({
updateText: (evt) => dispatch(updateText(evt.target.text))
});
// only exporting the connect function
export default connect(mapStateToProps, mapDispatchToProps);
/**
* moduleB.connector.js
*/
const mapStateToProps = (state) => ({
headline: state.headline
});
// only exporting the connect function
export default connect(mapStateToProps);
/**
* ModuleC.jsx
*/
export const ModuleC = ({ headline, text, updateText }) => (
<div>
<h1>{headline}</h1>
<DumbComponent text={text} onChange={updateText} />
</div>
);
export default compose(moduleAConnector, moduleBConnector)(ModuleC);
Benefits
- Better separation of concerns
- Better reusability
- A library of UI components
Key Ingredient #3
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 newBooks = yield call(booksApi.getBooks);
// dispatch GET_BOOKS_SUCCESS
yield put(getBooksSuccess(newBooks));
} 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
Key Ingredient #4
Working w/ Decoupled Modules
Redux at Scale
Code Splitting
System.import() tells webpack to make a js chunk and load that chunk when requested
// src/components/index.js
export const TwcHeader = () => System.import('@twc/header');
Very soon, decoupled modules will live in a mono-repo that will be considered the module registry
Module Registration
Dynamic imports means that we need to take steps to register the module after it's chunk is loaded
// module registration interface
async function registerModule({
injectReducer,
injectReducerWithKey,
runSaga,
selectorFactory, // create a selector using the reducer key from consuming app
// the properties below are subject to be removed
selectors, // selectors from consuming app
actions, // actions from consuming app
props // props from consuming app
}) {
// ...use the interface to register module
// must return a promise that resolves with component to render
}
injectReducer
Since the module is being loaded dynamically, we must inject the reducer into the store during module registration
import reducer from './ContentMedia.reducer';
async function registerModule({
// ...
injectReducer
}) {
// add reducer to redux store
injectReducer(reducer);
// ...finish module registration
}
// redux store
{
// ...other reducer keys,
'ContentMedia/as1d-231d-r12e-231r-adw3': reducer
}
injectReducerWithKey
Sometimes a module is shared and it's state needs to be accessed by other modules, in this case we need a static key
import reducer from './TwcHeader.reducer';
async function registerModule({
// ...
injectReducerWithKey
}) {
// add reducer to redux store with static key
injectReducerWithKey('twcHeader', reducer);
// ...finish module registration
}
// redux store
{
// ...other reducer keys,
'twcHeader': reducer
}
runSaga
export default function* twcHeaderSaga() {
yield call(loadTwcHeaderData);
// spawn watchers
yield spawn(watchChangeUnit);
}
import saga from './TwcHeader.saga';
async function registerModule({
// ...
runSaga
}) {
// run saga before returning component
await runSaga(saga).done;
// return component to render
}
Master Module Decoupling
- Use module registration to dynamically register your module when the page config requires it
- Use injectReducer to add your reducer to redux store
- Use injectReducerWithKey when you need a static key
- Use runSaga when you need to load data or perform logic before your component mounts
Key Ingredient #5
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
redux-saga
Brief overview and some gotchas
Redux at Scale
Forking Model
// blocking call
// must complete before saga execution continues
yield call(someTask);
// attached fork
// saga execution continues
// saga will not complete until forked task completed
yield fork(someTask);
// detached fork
// saga execution continues
// saga completion is not blocked by forked task
yield spawn(someTask);
call
fork
spawn
blocking task
non-blocking task, attached
non-blocking task, detached
Task Parallelization
yield call(task1);
yield call(task2); // must wait for task1
yield call(task3); // must wait for task2
yield [
call(task1),
call(task2),
call(task3)
];
// waits for all 3 tasks to complete, but runs them concurrently
take, takeEvery, takeLatest
// listens for the first ADD_TODO action and continues
const action = yield take('ADD_TODO');
// listens every ADD_TODO action and spawns a task
yield takeEvery('ADD_TODO', taskToSpawn);
// listens the latest ADD_TODO action and spawns a task
// if a previously spawned task is still running, it is cancelled
yield takeLatest('ADD_TODO', taskToSpawn);
Testing Sagas (generators)
describe('mySaga', () => {
let saga;
let mockUser;
before(() => {
saga = mySaga();
mockUser = {
id: '123',
name: 'Unit Tester'
};
});
it('should select user data from state', () => {
expect(saga.next().value).to.deep.equal(
select(userDataSelector)
);
});
it('should fetch new user data', () => {
expect(saga.next(mockUser).value).to.deep.equal(
call(fetchUserData, mockUser.id)
);
});
});
function* mySaga() {
const user = yield select(userDataSelector);
yield call(fetchUserData, user.id);
}
TL;DR
- Conform to the flux standard action spec
- Utilize "smart containers" and "dumb components"
- Conquer side-effects with redux-saga
- Decouple modules using the registration interface
- Create performant, composable selectors with reselect
Links
- ReactJS - facebook.github.io/react
- Redux - redux.js.org
- redux-thunk - github.com/gaearon/redux-thunk
- redux-saga - github.com/yelouafi/redux-saga
- reselect - github.com/reactjs/reselect
- FSA - github.com/acdlite/flux-standard-action
Joel Kanzelmeyer
@kanzelm3
Questions?
TWC - Taming Large React Applications w/ Redux
By Joel Kanzelmeyer
TWC - 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 using libraries like redux-saga and reselect to improve code re-use, removing coupling, and reducing complexity.
- 3,295