Concurrency with redux-saga

Building apps that know how to fail with grace
in the unpredictable world of the asynchronous web

 

  GENERATORS

before we start, a quick intro to ES6

let status = 'nothing';
function* simpleGen(x) {
  status = 'started';
  yield x * 2;
  yield x * 4;
  yield x * 6;
  status = 'done';
}

const it = simpleGen(1);
it.next();
function* twiceAndQuadruple(x) {
  yield x * 2;
  yield x * 4;
}

function* twiceAndQuadrupleForever(x) {
  const whatever = x;
  while(true) {
    yield* twiceAndQuadruple(x);
    x += 1;
  }
}

const x = twiceAndQuadrupleForever(1);
x.next();

Generators Deep Dive

function* speakToMe(name) {
  const y = yield `Hey! ${name}? How are you?`;
  const res = yield `Did you say ${y}?`;
  if (res === 'yes') {
  	yield 'Aight nice to meet you.';
  } else if (res) {
  	yield 'My bad!';
  }
  yield 'Aight, suit yourself bye.'
}


const x = speakToMe('Martin');
x.next();

Generators Deep Dive

function request({ url }) {
  fetch(url).then((data) => {
    it.next(data);
  });
}

function* main() {
  const res1 = yield request({ url: 'someUrl' });
  const { data: { id } } = JSON.parse(res1);
  const res2 = yield request({ url: `someUrl/${id}` });
  const output = JSON.parse(res2).data;
  console.log(output);
  yield output;
}

const it = main();
it.next();

Async Side Effect Management

enter sagas

  • In simple terms for our purposes; sagas are just interconnected stories of events.
     
  • The Saga pattern is a way to make things less brittle.
     
  • Saga are Long Lived Transactions that can be written as a sequence of transactions that can be interleaved.
     
  • All transactions in the sequence complete successfully OR compensating transactions are ran to semantically amend a partial execution.
     
  • Leads to consistency and application correctness

redux-saga

  • Redux saga is a library to declaratively control async side effects while making your code easier to understand, manage, reason about and test.
  • Provides a powerful toolset to orchestrate higher-level logic around network requests, user interaction events, etc.
  • Helps build intelligent, event driven & reactive front end applications.

benefits

  1. Declarative effects.
     

  2. Advanced async control flow
     

  3. concurrency management.
     

  4. Architectural benefits...SoC+colocation= maintainability

  • It helps easily design apps that are self aware and ready to be graceful in the face of failures.
  • Makes your action creators pure, as God intended them to be!
  • Provides mechanisms to orchestrate concurrent parallel tasks, and even cancel complex them at our accord, without spaghetti. Moreover, it looks like it's all synchronous code!
  • Isolates side effect code to a single domain
  • Colocation of all interdependent business logic makes codebase easier to understand and maintain; while staying separate from your React components or redux actions.

more insight I guess

effects at a glance

  • race
  • throttle
  • debounce
  • delay
  • undo
  • take
  • takeEvery
  • takeLatest
  • put
  • call
  • fork & spawn
function* dragWatcher({ payload }) {
  while(true) {
    yield take('MOUSE_DOWN');
    const nextAction = yield take('MOUSE_MOVED', 'MOUSE_UP');
    if (nextAction.type === 'MOUSE_MOVED') {
      yield put({ type: 'USER_STARTED_DRAGGING' });
      yield take('MOUSE_UP');
      yield put({ type: 'USER_STOPPED_DRAGGING' });
    }
  }
}

trivial example?

thunks

function login({ user, pass }) {
  return async (dispatch, getState) => {
    // await dispatch(api(....))  why!!?  :(((
    try {
      await api({
        method: 'GET',
        path: '/v2/users/login',
      });
      dispatch({ type: 'LOGIN_SUCCESS' });
      dispatch(fetchSuperPrivateInfo());
    } catch (e) {
      dispatch({ type: 'LOGIN_FAILED' });
    }
  };
}

function logout() {
  return async (dispatch, getState) => {
    try {
      await cleanup();
      dispatch({ type: 'LOGOUT_SUCCESS' });
    } catch (e) {
      dispatch({ type: 'LOGIN_FAILED' });
    }
  };
}
function fetchSuperPrivateInfo() { ... }

saga

function* authSaga() {
  try {
    const token = yield call(api, {
      method: 'GET',
      path: '/v2/users/login',
    });
  
    yield put({ type: 'LOGIN_SUCCESS', payload: { token } });
    const task = yield fork(getSuperPrivateInfo); // composable!!!
    yield take('LOGOUT_REQUEST');
    yield cancel(task);
    // getSuperPrivateInfo can abort fetch/do cleanup, whatever!
    try {
      yield call(cleanup);
      yield put({ type: 'LOGOUT_SUCCESS' });
    } catch (e) {
      yield put({ type: 'LOGIN_FAILED' });
    } // yield* logoutSaga();
  } catch (e) {
    yield put({ type: 'LOGIN_FAILED' });
  }
}

function* getSuperPrivateInfo() {}
function* cartRemovalConsequenceManager(action) {
  const { payload: { product } } = action;
  const { type, product } = yield take([
    'REMOVE_FROM_CART_SUCCESS',
    'REMOVE_FROM_CART_ERROR',
  ]);
  
  if (type === REMOVE_FROM_CART_ERROR) {
    yield put(addToLocalCart(product));
    yield put(addNotification('Please try again.'));
  }
 
  if (type === 'REMOVE_FROM_CART_SUCCESS') {
    yield put(saveToRecentRemovals(product));
    const { undo } = yield race({
      undo: take(action => (
        action.type === UNDO_CART_REMOVAL
        && action.payload === product.id
      )),
      timeup: delay(7500),
    });

    yield put(removeFromRecentRemovals(product.id));

    if (undo) {
      yield put(addToCart(product));
    }
  }
}

// state = {
//  recentRemovals: [],
//  cartItems: [],
//}

simple undo

function* likeSaga(id) {
  try {
    const response = yield call(api.likePost, id);
    yield put(likePostSuccess(response));
  } catch (e) {
    yield put(likePostError(e.message));
  } finally {
    yield call(api.unlikePost, id);
  }
}

function* likeWatcher({ payload }) {
  const { id } = payload;
  yield put(likeOptimistically(payload));
  // shameless Ramda plug
  const { userUndid, timeup } = yield race({
    userUndid: take(allPass([
      propEq('type', UNLIKE_POST_REQUEST),
      pathEq(['payload', 'id'] === id),
    ])),
    timeup: delay(5000),
  });

  if (userUndid) {
    yield put(undoOptimisticLike(id));
  } else if (timeup) {
    const task = yield fork(likeSaga, id);
    const { type } = yield take([
      axn => axn.type === UNLIKE_POST_REQUEST && axn.payload.id === id,
      axn => axn.type === LIKE_POST_SUCCESS && axn.payload.id === id,
      axn => axn.type === LIKE_POST_ERROR && axn.payload.id === id,
    ]);
    if (type === UNLIKE_POST_REQUEST) {
      yield cancel(task);
    }
  }
}

where sagas truly shine for me

  • Cancel an ongoing if the user actively cancels it interacting with the UI
  • Log *all* kinds of user actions for analytics.
  • React to a multiple or a specific sequence of events that might or might not be user interaction based.
  • Handle impatient users and their wild fingers with grace!
  • Infinite loops that work as daemons, but are slaves to a parent saga that might kill them...refresh authentication tokens reliably, or perform some similar "background" task intelligently.

  • Retrying tasks...with granular control over cancel/retry strategies.

benefits in retrospect

  • Synchronous looking code that abstracts over the asynchronicity.
  • Allows for very complex flows without managing state machines or running into race conditions.
  • Composable, cancellable, and we decide whether effect is blocking or runs in parallel, while still being able to control the composed effect!
  • Handle impatient users and their wild fingers with grace!
  • A pure system with pure actions that can be OBSERVED to perfect granularity without relying on "Network" tab or searching tens of components to figure out what the heck is going on. Real time travel.

  • Ability to change app logic at one place, even though the affected components are many!

  • Easy testing. Tests that are "quick" and don't take more time than writing the actual functionality (I'm looking at you, async thunks!)

redux-saga

By Aviral Kulshreshtha