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

facebook.github.io/react

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

Joel Kanzelmeyer

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,121