Real-life experiences with redux-saga

Including ES6 generators, TypeScript 2.1, and more!

React Helsinki 22.2.2017
by Ville Saarinen, @vsaarinen

My background

Interactive data visualisation

Custom projects

Minard

A preview server for static web projects

Tech stack

Backend

  • TypeScript
  • Node + Hapi
  • Docker
  • GitLab

Frontend

  • TypeScript
  • React
  • redux
  • redux-saga

Async operations in React

  • Inline

// component.tsx
const loadData = (dispatch: Dispatch<any>, id: string) => {
  fetch(`/api/data/${id}`)
    .then(response =>
      response.ok ? response.json() : Promise.reject(response.error)
    ).then(json => {
      dispatch({
        type: 'STORE_DATA',
        data: json
      });
    }).catch(error => {
      dispatch({
        type: 'DATA_FETCH_FAILED',
        error: error || 'Something bad happened'
      });
    });
};

loadData(this.props.dispatch, this.props.id);
          

Problems

  • Duplicating async code
  • Handling race conditions 😬

Async operations in React

  • Inline
  • Centralized logic (aka. action creators)

// actions.ts
const storeData = (data: any) => ({ type: 'STORE_DATA', data });
const fetchFailed = (error: string) => ({ type: 'FETCH_FAILED', error });
let inProgressFetches: string[] = [];

export const loadData = (dispatch: Dispatch<any>, id: string) => {
  if (inProgressFetches.indexOf(id) > -1) return;

  inProgressFetches.push(id);
  fetch(`/api/data/${id}`)
    .then(response =>
      response.ok ? response.json() : Promise.reject(response.error)
    ).then(json => {
      dispatch(storeData(json));
      inProgressFetches = inProgressFetches.filter(n => n !== id);
    }).catch(error => {
      dispatch(fetchFailed(error));
      inProgressFetches = inProgressFetches.filter(n => n !== id);
    });
};

// component.tsx
import { loadData } from './actions';

loadData(this.props.dispatch, this.props.id);
          

Problems

  • Passing dispatch around
  • Two different types of action creators:
    • Synchronous object creators
    • Asynchronous dispatchers

Async operations in React

  • Inline
  • Centralized logic (aka. action creators)
  • redux-thunk

// actions.ts
const storeData = (data: any) => ({ type: 'STORE_DATA', data });
const fetchFailed = (error: string) => ({ type: 'FETCH_FAILED', error });
let inProgressFetches: string[] = [];

export const loadData = (id: string) => (dispatch: Dispatch) => {
  if (inProgressFetches.indexOf(id) > -1) return;

  inProgressFetches.push(id);
  fetch(`/api/data/${id}`)
    .then(response =>
      response.ok ? response.json() : Promise.reject(response.error)
    ).then(json => {
      dispatch(storeData(json));
      inProgressFetches = inProgressFetches.filter(n => n !== id);
    }).catch(error => {
      dispatch(fetchFailed(error));
      inProgressFetches = inProgressFetches.filter(n => n !== id);
    });
};

// component.tsx
import { loadData } from './actions';

this.props.dispatch(loadData(this.props.id));
          

Problems

  • …no major ones
  • Implementing certain logic is difficult:
    • Retrying requests
    • Reauth with tokens
    • Cancelling requests

Async operations in React

  • Inline
  • Centralized logic (aka. action creators)
  • redux-thunk
  • redux-saga

Refresher: ES6 generators


function *generator() {
  let i = 1;

  while (true) {
    yield i++;
  }
}

const iter = generator();
iter.next(); // => { value: 1, done: false }
iter.next(); // => { value: 2, done: false }
iter.next(); // => { value: 3, done: false }
          

function *twoWayGenerator() {
  console.log('inside: starting');
  const firstYieldValue = yield 1;
  console.log('inside: firstYieldValue', firstYieldValue);
  const secondYieldValue = yield 2;
  console.log('inside: secondYieldValue', secondYieldValue);
  return 3;
}
          


const iter2 = twoWayGenerator();
iter2.next();
// 'inside: starting'
// => { value: 1, done: false }

iter2.next('hi there');
// 'inside: firstYieldValue', 'hi there'
// => { value: 2, done: false }

iter2.next('oh my');
// 'inside: secondYieldValue', 'oh my'
// => { value: 3, done: true }

iter2.next()
// => { value: undefined, done: true }
          

Async operations in React

  • Inline
  • Centralized logic (aka. action creators)
  • redux-thunk
  • redux-saga

redux-saga

Sagas are like long-running background processes.
The idea comes from the database field, from a paper published in 1987.

redux-saga is a redux middleware that provides saga functionality using ES6 generators.

redux-saga allows you to intercept redux actions and do funky stuff using effects.

redux-saga effects

redux-saga effects are plain old Javascript objects. They are yielded to the redux-saga middleware to tell it what to do.

redux-saga comes with effect generators—functions that return appropriately formatted objects.


const effect = effectCreator(...args);
yield effect;
          

redux-saga effects

  • take: Wait until the specified Redux action and return it.
  • put: Pass the supplied action to the Redux dispatcher.
  • select: Get a value out of the Redux state tree.
  • call: Blocking. Call the given function or generator, returning its (resolved) value.
  • fork: Non-blocking. Call the given function or generator, returning a reference to it.
  • join: Wait until the referenced forked function terminates.
  • cancel: Cancel the referenced forked function.
  • race: Return when one of the given functions returns, cancelling the rest.

Setting it up


// createStore.ts
import { applyMiddleware, createStore } from 'redux';
import saga from './sagas';
import reducer from './reducers';

const sagaMiddleware = createSagaMiddleware();
const store = createStore(reducer, {}, applyMiddleware(sagaMiddleware));
sagaMiddleware.run(saga);
          

Triggering


// component.tsx
dispatch({ type: 'LOAD_DATA', id });


// sagas/index.ts
export default function* watchForLoadData(): IterableIterator<Effect> {
  while (true) {
    const { id } = yield take('LOAD_DATA');

    yield fork(loadData, id);
  }
}

function* loadData(id: string): IterableIterator<Effect> {
  const { response, error } = yield call(api.fetchData, id);

  if (response) {
    yield put({ type: 'STORE_DATA', id, data: response.data });
  } else {
    yield put({ type: 'FETCH_ERROR', id, error });
  }
}
          

Recipes

Parallel tasks


function *fetchUsers(userIds: string[]): IterableIterator<Effect[]> {
  const users = yield userIds.map(id => call(fetchUser, id));
  yield put({ type: 'STORE_USERS', users });
}
          

Recipes

Timeouts


const delay = (ms: number) => new Promise(res => setTimeout(res, ms));

function* fetchPostsWithTimeout(): IterableIterator<Effect> {
  const { posts, timeout } = yield race({
    posts: call(fetchPosts),
    timeout: call(delay, 1000)
  });

  if (posts)
    put({ type: 'POSTS_RECEIVED', posts });
  else
    put({ type: 'TIMEOUT_ERROR' });
}
          

Recipes

Cancelling


function* backgroundTask(): IterableIterator<Effect> {
  while (true) { ... }
}

function* watchStartBackgroundTask(): IterableIterator<Effect> {
  while (true) {
    yield take('START_BACKGROUND_TASK');
    yield race({
      task: call(backgroundTask),
      cancel: take('CANCEL_TASK')
    });
  }
}
          

Recipes

Debouncing


const delay = (ms: number) => new Promise(res => setTimeout(res, ms));

function* handleInput(input: string): IterableIterator<Effect> {
  // debounce by 500ms
  yield call(delay, 500);
  ...
}

function* watchInput() {
  let task: Task;
  while (true) {
    const { input } = yield take('INPUT_CHANGED');
    if (task) {
      yield cancel(task);
    }
    task = yield fork(handleInput, input);
  }
}
          

Example

Token refreshing


function* authorize(credentials: Token | { name: string, password: string }) {
  // api.authorize throws on error
  const token = yield call(api.authorize, credentials);
  yield put(login.success(token));
  return token;
}

function* authAndRefreshTokenOnExpiry(name: string, password: string) {
  let token = yield call(authorize, { name, password });
  while(true) {
    yield call(delay, token.expires_in);
    token = yield call(authorize, token);
  }
}

/* Continued... */
          

/* Continued... */

function* watchAuth() {
  while(true) {
    try {
      const { name, password } = yield take(LOGIN_REQUEST);

      yield race([
        take(LOGOUT),
        call(authAndRefreshTokenOnExpiry, name, password);
      ])
    } catch(error) {
      yield put(login.error(error));
    }
  }
}
          

Question

Why does call exist?
Why not just call the function or generator directly from your saga?

Testing!

Unit testing with redux-saga

All you're doing is building generators that produce effects, i.e. plain JavaScript objects. No need to mock whatever you're "calling".

Testing becomes functional, with no side-effects.

Tests


const iterator = loginSaga();

assert.deepEqual(iterator.next().value, take(LOGIN_REQUEST));

const mockAction = { user: '...', pass: '...' };
assert.deepEqual(
  iterator.next(mockAction).value,
  call(request.post, '/login', mockAction)
);

const mockError = 'invalid user/password'
assert.deepEqual(
  iterator.throw(mockError).value,
  put({ type: LOGIN_ERROR, error: mockError })
);
          

Downsides of redux-saga

  • No need to mock actual objects/functions, but you need to mock their data and behavior. The implementation details of the saga also need to be known. This is, by far, my biggest problem with redux-saga.
  • Not all browsers support generators. Need to use babel (until TypeScript 2.3 comes out!).
  • Probably overkill for many use cases.

Upsides of redux-saga

  • All logic with side-effects in one place. Allows you to focus on being declarative in React.
  • Even though code is async, it's very easy to follow.
  • Effects allow you to compose very powerful operations.
  • Mocking objects/functions not required for unit tests.

redux-thunk vs redux-saga

i.e. push vs pull

That's about it!

Questions? If we have time and any of you are interested, I'd be more than happy to talk about e.g. TypeScript.

Want to pilot Lucify's Minard?

If you're working on static web projects (i.e. projects with no backend in the repo), come talk! We're launching our closed alpha soon.

Acknowledgements