Learning Redux-Sagas Together

@vjustov

Basics
How we use it
Q & A

What is redux-sagas?

"What's a middleware?" - I hear you ask

A middleware is some code you can put between dispatching an action, and the moment it reaches the reducer

"And a Side effect?"

Asynchronous things like data fetching and impure functions like accessing the browser cache.

 

firing tracking events, setting up a subscription, and manually changing the DOM in React components are all examples of side effects.

But can't I do that right now?

and making the call inside component?

class App extends Component {
  constructor() {
    super();
    this.state = { data: [] };
  }

  componentDidMount() {
    fetch(`https://api.coinmarketcap.com/v1/ticker/?limit=10`)
      .then(res => res.json())
      .then(json => this.props.requestSuccess(json));
      .catch(e => {
        switch (e.response.status) {
          case 409: {}
          default: {}
        }
        this.props.requestFailed(e)
      })
  }

  render() {
    return (<p>{this.props.data}</p>);
  }
}

Isn't this exactly what                              solves?

Redux-Effects

Redux-Observable

Redux-Thunk

Redux-Loop  

Based on some readings and my experience...

Use Thunk instead of Saga for simple and trivial tasks like:

  • AJAX calls
  • data polling and only if they are started directly by the user interaction.

Use Saga for

  • intertwined tasks, the login example of the docs is perfect
  • flows with a lot of steps and waitings for other conditions to happen ("finite-state machine" flows)
  • tasks that work in the background and proceed independently from the user interaction (or a mix of background/interactions)

But how do sagas work?

Cue the                    function

 

               generator                *

They use Iterators

an iterator object for the function is returned instead. When the iterator's next() method is called, the generator function's body is executed until the first yield expression

Iterators can pass values

function* anotherGenerator() {
  const string = "some code" 
  const who = "world"
  yield who	
  string.concat(" more code")
}

const gen = anotherGenerator().next()
const gretee = gen.next().value

`hello, ${gretee}`
=> hello, world

And also receive them

const gen = translationGenerator().next()
const translation = gen.next("lapiz")
translation.value
=> "stift"


function* translatorGenerator() {
    const spanishWord = yield;
    return dictionary[spanishWord];
}
export function* fetchData() {
  try {
   const data = yield axios.get('/user?ID=12345')
   yield dispatch({type: "FETCH_SUCCEEDED", data})
 } catch (error) {
   yield dispatch({type: "FETCH_FAILED", error})
 }
}
function thunkActionCreator(forPerson) {
  return function(dispatch) {
    return axios.get('/user?ID=12345').then(
      (data) => dispatch({type: "FETCH_SUCCEEDED", data}),
      (error) => dispatch({type: "FETCH_FAILED", error}),
    );
  };
}

Remember what we said about generators being unable to restart themselves?

function middleware(store) {
  return next => action => {
    const result = next(action)
    
    const iter = saga(action)
    process(iter)
    
    return result
  }
}

function process(iterator) {
  function iterate(arg) {
    const yielded = iterator.next(arg)
    
    if (!yielded.done) {
      digestEffect(result.value, iterate)
    }
  }
  iterate()
}

Yeah, that doesn't tell me much...


function digestEffect(
  effect, 
  nextCallback /* iterate() */
) {
  if (is.simpleValue(effect)) { 
    nextCallback(effect) 
  }
  else if (is.promise(effect)) { 
    effect.then(nextCallback) 
  }
}

Effects

The other nice thing Sagas provide.

takeLatest("EXAMPLE", ()=>{})
{
  // ...
  type: 'FORK',
  payload: {
    fn: ƒ takeLatest(patternOrChannel, worker),
    args: ['EXAMPLE', () => {}]
  }
};
call(getLoggedInDetails)
{
  // ...
  type: 'CALL',
  payload: {
    context: null,
    fn: f getloginDetails(),
    args: []
  }
};
put(getLoggedInDetails())
{
  // ...
  type: 'PUT',
  payload: {
    action: {
      type: 'app/GET_LOGGED_IN_DETAILS_IN_PROGRESS'
    }
  }
};
  • take(...)
  • takeEvery(...)
  • takeLatest(...)
  • put(...)
  • call(...)
  • select(...)
  • fork(...)
  • cancel(...)

Let's digest those effects.


function digestEffect(
  effect, 
  nextCallback /* iterate() */
) {
  if (is.simpleValue(effect)) { 
    nextCallback(effect) 
  }
  else if (is.promise(effect)) { 
    effect.then(nextCallback) 
  }
  else if (is.effectObj(effect)) { 
    const effectRunner = effectRunnerMap[effect.type]
    effectRunner(effect.payload, nextCallback)
  } else if (is.iterator(effect)) {
    // The process function from before
  	process(effect) 
  }
}
export function* fetchData() {
  try {
   const data = yield call(axios.get, '/user?ID=12345')
   yield put({type: "FETCH_SUCCEEDED", data})
 } catch (error) {
   yield put({type: "FETCH_FAILED", error})
 }
}

This makes testing easier

export function* fetchData(action) {
  const data = yield call(callApi, action.payload.url)
}

const gen = fetchData()

assert.deepEqual(
  gen.next().value,
  {
    CALL: {
      fn: Api.fetch,
      args: ['./products']
    }  
  },
  'it should call the fetch api'
);

Root Saga and watchers

export default function* rootSaga() {
  yield fork(watchProductSaga)
  yield fork(watchProductContentSaga)
  yield fork(watchProductReviewsSaga)
  yield fork(watchProductStockSaga)
  yield fork(watchUserIdentityIdSaga)
  yield fork(watchUserAddToBasketSaga)
  yield fork(watchFactFinderSaga)
  yield fork(watchLocationChange)
  yield fork(watchBrandPageDetails)
  yield fork(watchCampaignPageDetails)
  yield fork(watchCategoryPageDetails)
  yield fork(watchTvShowSaga)
  yield fork(watchStockReminderSaga)
}

Watcher/Worker

 

refers to a way of organizing the control flow using two separate Sagas

  • The watcher: will watch for dispatched actions and fork a worker on every action

  • The worker: will handle the action and terminate

function* watchFetchData() {
  yield takeEvery(
    'FETCH_REQUESTED', 
    fetchData
  )
}

function* fetchData(action) {
  try {
   const data = yield axios.get('/user?ID=' + action.payload)
   yield dispatch(requestSuccessfull(data))
 } catch (error) {
   yield dispatch(requestFailed(error))
 }
}

Back to the Middleware

A channel is an object used to send and receive messages between tasks. Messages from senders are queued until an interested receiver request a message, and registered receiver is queued until a message is available.

function sagaMiddleware() { 
  return function middleware(store) {
    return next => action => {
      const result = next(action)

      channel.put(action)

      const iter = saga(action)
      process(iter)

      return result
    }
  }
}

function process(iterator) {
  function iterate(arg) {
    const yielded = iterator.next(arg)
    
    if (!yielded.done) {
      digestEffect(result.value, iterate)
    }
  }
  iterate()
}

Fork effect runner

 

fork, like call, can be used to invoke both normal and Generator functions. But, the calls are non-blocking, the middleware doesn't suspend the Generator while waiting for the result of fn. Instead as soon as fn is invoked, the Generator resumes immediately.

function runForkEffect(fn, ...args, cb) {
  const taskIterator = createTaskIterator(fn, args)

  const child = process(taskIterator)
  cb(child)
  
}

Take effect runner

 

Creates an Effect description that instructs the middleware to wait for a specified action on the Store. The Generator is suspended until an action that matches pattern is dispatched.

 

The result of yield take(pattern) is an action object being dispatched.

function runTakeEffect(channel, pattern, cb) {
  try {
    channel.take(cb, pattern)
  } catch (err) {
    cb(err, true)
    return
  }
}

Basics
How we use it
Q & A

Api Calls

We extract api calls into their own self-contained sagas

Orchestrator Sagas

Handle Logic by composing a lot of different smaller sagas.

export function* loginAndRedirectUser(action) {
  const { 
    username, password, url 
  } = action.payload;
  
  yield put(
    loginUser(username, password)
  );
  yield take(
    actionTypes.REQUEST_SUCCESS
  );
  yield put(getLoggedInDetails());
  updateLoginState();
  yield url.redirect()
}

How we test

describe(sagaWatcher, () => {
  it('listens the EXAMPLE ActionType', () => {
    const iterator = sagaWatcher();

    const actualYield = iterator.next().value;

    expect(actualYield.type).toEqual('FORK');
    expect(actualYield.payload.args).toEqual(
      expect.arrayContaining(['EXAMPLE_ACTION_TYPE', saga])
    );
  });
});

describe(saga, () => {
  const iterator = loginAndRedirectUser({type: "EXAMPLE"});
  const effects = effectsFrom(iterator);

  it('logs in the user', async () => {
    expect(effects).toEqual(
      expect.arrayContaining([
        expect.objectContaining({
          type: 'PUT',
          payload: expect.objectContaining({
            action: expect.objectContaining({
              type: "ANOTHER_ACTION_TYPE"
            })
          })
        })
      ])
    );
  });
});

Going Crazy

                Fancy

Races

Let's wait for the first thing to happen of the following list

export function* trackLoginResult(loginMethod) {
  const winner = yield race({
    success: take(LOGIN_USER_REQUEST_SUCCESS),
    error: take(LOGIN_USER_REQUEST_FAILED)
  });

  if (winner.success) {
    const loginSuccess = {
      loginMethod,
      ...winner.success.payload
    };
    yield put(trackLoginSuccess(loginSuccess));
  } else {
    const loginFailedReason = winner.error.reason;

    const loginFailedDetailed = {
      loginFailedReason,
      loginMethod
    };
    yield put(trackLoginFailure(loginFailedDetailed));
  }
}

Dont be afraid of Take()

And While(true)

Queuing up GTM Events

 

Basically, Queues tracking events to be dispatched only after a pageImpression is tracked.

export function* trackingQueue() {
  yield take(LOCATION_CHANGE);
  while (true) {
    const chn = yield actionChannel(
      isTrackingAction
    );
    const impression = yield take(
      TRACK_PAGE_IMPRESSION
    );
    yield put(writeToDL(impression));

    while (true) {
      const winner = yield race({
        locationChanged: take(
          LOCATION_CHANGE
        ),
        trackingAction: take(chn)
      });

      if (winner.trackingAction) {
        yield put(writeToDL(
          winner.trackingAction
        ));
      } else {
        break;
      }
    }
  }
}

Basics
How we use it
Q & A

Returning effects from classes?

Should our classes know about saga effects?

export class RedirectUrl {
  //...

  redirect(): CallEffect | PutEffect {
    if (this.isAbsolute()) {
      return call(redirectTo, this.value);
    } else {
      return put(push(this.value));
    }

}

Thank
You
<3

Learning Redux-Sagas Together

By Viktor Ml. Justo Vasquez

Learning Redux-Sagas Together

  • 424