Real-life experiences with redux-saga

ES6 generators, TypeScript 2.0, and more!

Futurice WWWeeklies 26.8.2016
by Ville Saarinen, @vsaarinen

My background

Minard

A preview server for static web projects

Demo

Tech stack

Backend

  • TypeScript 2.0
  • Hapi
  • Docker
  • GitLab

Frontend

  • TypeScript 2.0
  • 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

// 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
  • 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
  • redux-thunk
  • redux-saga

Refresher: ES6 generators


function *generator() {
  let i = 1;

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

const iterator = generator();
          

function *testingGenerators() {
  console.log('inside: starting');
  const firstYieldReturn = yield 1;
  console.log('inside: firstYieldReturn', firstYieldReturn);
  const secondYieldReturn = yield 2;
  console.log('inside: secondYieldReturn', secondYieldReturn);
  return 3;
}

const iterator2 = testingGenerators();
          

Async operations in React

  • Inline
  • Centralized logic
  • 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 simply Javascript objects. They are yielded to the redux-saga middleware to tell it what to do.

The redux-saga library comes with built-in support for certain types of effects, along with effect creators. Using these primitives, you can do some really powerful stuff.

redux-saga effects

  • take: Wait until the specified Redux action and return it.
  • put: Pass the given action to the Redux store.
  • select: Get a value out of the Redux state tree.
  • call: Call the given function (blocking), returning its value.
  • fork: Call the given function (non-blocking), 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


// 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 });
  }
}


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

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);
  }
}
          

Actual code

Question

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

Testing becomes awesome!

Unit testing with redux-saga

aka. mocking is for suckers

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

Testing becomes functional, with no side-effects.

Actual tests

Downsides of redux-saga

  • Not all browsers support generators
  • Testing requires (some) knowledge of implementation
  • Probably overkill for many use cases

Upsides of redux-saga

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

That's about it!

If we have time and any of you are interested, I'd be more than happy to talk about: TypeScript 2.0, what to store in the Redux state while loading in progress or errors, structuring React applications...

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 in November.

Acknowledgements