Redux Saga

Лекция 30

Redux Saga

  • удобная работа с сайд-эффектами
  • на генераторах
  • декларативная
  • легкое тестирование
  • redux-saga.js.org

Redux Saga


npm install redux-saga
    

<script
  src="https://unpkg.com/redux-saga/dist/redux-saga.js"
></script>
    

Redux Saga


import { call, put } from 'redux-saga/effects'

export function * fetchData(action) {
 try {
  const data = yield call(
    Api.fetchUser,
    action.payload.url
  )

  yield put({type: "FETCH_SUCCEEDED", data})
 } catch (error) {
  yield put({type: "FETCH_FAILED", error})
 }
}
    

Redux Saga


import { takeEvery } from 'redux-saga/effects'

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

Redux Saga


import {
  createStore,
  applyMiddleware
} from 'redux';

import createSagaMiddleware from 'redux-saga'

import reducer from './reducers';

import sagas from './sagas/watchFetchData';
    

Redux Saga


const sagaMiddleware = createSagaMiddleware();

const store = createStore(
    reducer,
    /* initial state */,
    applyMiddleware(sagaMiddleware)
);

sagaMiddleware.run(sagas);

export default store;
    

Redux Saga


import { takeEvery } from 'redux-saga'

// FETCH_USERS
function* fetchUsers(action) { ... }

// CREATE_USER
function* createUser(action) { ... }

// use them in parallel
export default function* rootSaga() {
  yield takeEvery('FETCH_USERS', fetchUsers)
  yield takeEvery('CREATE_USER', createUser)
}
    

Redux Saga - Effects


// эффект - объект с инструкциями
// для sagaMiddleware о необходимых действиях
yield call(
  Api.fetchUser,
  action.payload.url
)

=>

{
  CALL: {
    fn: Api.fetch,
    args: ['/users/42']
  }
}
    

Redux Saga - Why Effects?


import { takeEvery } from 'redux-saga/effects'
import Api from './path/to/api'

function* watchFetchProducts() {
  yield takeEvery(
    'PRODUCTS_REQUESTED',
    fetchProducts
  )
}

function* fetchProducts() {
  // yield a Promise
  const products = yield Api.fetch('/products')

  console.log(products)
}
    

Redux Saga - Why Effects?


// trying write a test
const iterator = fetchProducts()
assert.deepEqual(iterator.next().value, ??)

// need to mock Api.fetch
    

Redux Saga - Why Effects?


import { call } from 'redux-saga/effects'

function* fetchProducts() {
  const products = yield call(
    Api.fetch,
    '/products'
  )
}

=>

{
  CALL: {
    fn: Api.fetch,
    args: ['./products']
  }
}
    

Redux Saga - Why Effects?


import { call } from 'redux-saga/effects'
import Api from '...'

const iterator = fetchProducts()

assert.deepEqual(
  iterator.next().value,
  call(Api.fetch, '/products')
)
    

Redux Saga - Complex logic


import { take, put } from 'redux-saga/effects'

function* watchFirstThreeTodosCreation() {
  for (let i = 0; i < 3; i++) {
    const action = yield take('TODO_CREATED')
  }

  yield put({type: 'SHOW_CONGRATULATION'})
}
    

Redux Saga - Non-blocking


import { fork, call, take, put, cancel }
  from 'redux-saga/effects'
import Api from '...'

function* authorize(user, password) {
  try {
    const token = yield call(
      Api.authorize,
      user, password
    )
    yield put({type: 'LOGIN_SUCCESS', token})
    yield call(Api.storeItem, {token})
  } catch(error) {
    yield put({type: 'LOGIN_ERROR', error})
  }
}
...
    

Redux Saga - Non-blocking


...
function* loginFlow() {
  while (true) {
    const {user, password}
      = yield take('LOGIN_REQUEST')

    const task = yield fork(authorize, user, password)

    const action
      = yield take(['LOGOUT', 'LOGIN_ERROR'])

    if (action.type === 'LOGOUT')
      yield cancel(task)

    yield call(Api.clearItem, 'token')
  }
}
    

Redux Saga - Non-blocking


function* authorize(user, password) {
  try {
    ...
  } catch(error) {
    ...
  } finally {
    if (yield cancelled()) {
      // cancellation handling code here
    }
  }
}
    

Redux Saga - Parallel


import { all, call } from 'redux-saga/effects'

// correct, effects will get executed in parallel
const [users, repos]  = yield all([
  call(fetch, '/users'),
  call(fetch, '/repos')
])
    

Redux Saga - Race


import { race, take, put } from 'redux-saga/effects'
import { delay } from 'redux-saga'

function* fetchPostsWithTimeout() {
  const {posts, timeout} = yield race({
    posts: call(fetchApi, '/posts'),
    timeout: call(delay, 1000)
  })

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

Redux Saga - Race


// other tasks are autocancel
import { race, take, put } from 'redux-saga/effects'

function* backgroundTask() {
  while (true) { ... }
}

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

Redux Saga - Outside of Redux


import { runSaga } from 'redux-saga'

function* saga() { ... }

const myIO = {
  subscribe: ...,
  dispatch: ...,
  getState: ...,
}

runSaga(
  myIO
  saga,
)
    

Redux Saga - Channels


import { take, actionChannel, call, ... }
  from 'redux-saga/effects'

function* watchRequests() {
  // will buffer messages
  const requestChan
    = yield actionChannel('REQUEST')

  while (true) {
    const {payload} = yield take(requestChan)

    yield call(handleRequest, payload)
  }
}

function* handleRequest(payload) { ... }
    

Redux Saga - Channels


import { eventChannel, END } from 'redux-saga'

function countdown(secs) {
  return eventChannel(emitter => {
      const iv = setInterval(() => {
        secs -= 1
        if (secs > 0) emitter(secs)
        else emitter(END)
      }, 1000);

      return () => {
        clearInterval(iv)
      }
    }
  )
}
    

Redux Saga - Channels


function* saga() {
  const chan = yield call(countdown, value)

  try {
    while (true) {
      // take(END) will cause the saga
      // to terminate by jumping
      // to the finally block
      let seconds = yield take(chan)

      console.log(`countdown: ${seconds}`)
    }
  } finally {
    console.log('countdown terminated')
  }
}
    

Redux Saga - Channels


function createSocketChannel(socket) {
  return eventChannel(emit => {
    const pingHandler = (event)
      => emit(event.payload)

    socket.on('ping', pingHandler)

    return ()
      => socket.off('ping', pingHandler)
  })
}
    

Redux Saga - Channels


function* pong(socket) {
  yield call(delay, 5000)
  yield apply(socket, socket.emit, ['pong'])
}
    

Redux Saga - Channels


function* watchOnPings() {
  const socketChannel
    = yield call(createSocketChannel, socket)

  while (true) {
    const payload = yield take(socketChannel)

    yield put({
      type: INCOMING_PONG_PAYLOAD, payload
    })

    yield fork(pong, socket)
  }
}
    

Redux Saga - Throttling


import { throttle } from 'redux-saga/effects'

function* handleInput(input) {
  // ...
}

function* watchInput() {
  yield throttle(500,
    'INPUT_CHANGED',
    handleInput
  )
}
    

Redux Saga - Debouncing


import { delay } from 'redux-saga'

function* handleInput(input) {
  yield call(delay, 500)
  ...
}

function* watchInput() {
  let task
  while (true) {
    const { input } = yield take('INPUT_CHANGED')

    if (task) yield cancel(task)

    task = yield fork(handleInput, input)
  }
}
    

Redux Saga - Debouncing


import { delay } from 'redux-saga'

function* handleInput({ input }) {
  yield call(delay, 500)
  ...
}

function* watchInput() {
  // will cancel current running handleInput task
  yield takeLatest('INPUT_CHANGED', handleInput);
}
    

Redux Saga - Retry


import { delay } from 'redux-saga'
import { call, put, take } from 'redux-saga/effects'

function* updateApi(data) {
  ...
}

export default function* updateResource() {
  ...
}
    

Redux Saga - Retry


// updateApi
for(let i = 0; i < 5; i++) {
  try {
    return yield call(
      apiRequest, { data }
    );
  } catch(err) {
    if(i < 4) {
      yield call(delay, 2000);
    }
  }
}

// attempts failed after 5 attempts
throw new Error('API request failed');
    

Redux Saga - Retry


// updateResource
while (true) {
  const { data } = yield take('UPDATE_START');
  try {
    const apiResponse = yield call(updateApi, data);
    yield put({
      type: 'UPDATE_SUCCESS',
      payload: apiResponse.body,
    });
  } catch (error) {
    yield put({
      type: 'UPDATE_ERROR',
      error
    });
  }
}
    

Redux Saga - Undo


import { take, put, call, spawn, race } from 'redux-saga/effects'
import { delay } from 'redux-saga'
import { updateThreadApi, actions } from 'somewhere'

function* onArchive(action) {
  ...
}

export default function* main() {
  while (true) {
    const action = yield take('ARCHIVE_THREAD')

    yield spawn(onArchive, action)
}
    

Redux Saga - Undo


// onArchive
const { threadId } = action
const undoId = `UNDO_ARCHIVE_${threadId}`

const thread = { id: threadId, archived: true }

yield put(actions.showUndo(undoId))
yield put(actions.updateThread(thread))

const { undo, archive } = yield race({
  undo: take(action => action.type === 'UNDO'
      && action.undoId === undoId),
  archive: call(delay, 5000)
})

...
    

Redux Saga - Undo


// onArchive
...

yield put(actions.hideUndo(undoId))

if (undo) {
  yield put(actions.updateThread({
    id: threadId,
    archived: false
  }))
} else if (archive) {
  yield call(updateThreadApi, thread)
}
    

Redux Saga - API # takeEvery


takeEvery(pattern, saga, ...args)
takeEvery(channel, saga, ...args)
// pattern: String | Array | Function
// saga: Generator
// args: Array
// channel: Channel
    

Redux Saga - API # takeLatest


takeLatest(pattern, saga, ...args)
takeLatest(channel, saga, ...args)
// pattern: String | Array | Function
// saga: Generator
// args: Array
// channel: Channel
    

Redux Saga - API # throttle


throttle(ms, pattern, saga, ...args)
// ms: Number
// pattern: String | Array | Function
// saga: Generator
// args: Array
    

Redux Saga - API # take


take(pattern)
take(channel)

take()
take('*')

// саги не уничтожаются после специального
// события END
take.maybe(pattern)
take.maybe(channel)
    

Redux Saga - API # put


put(action)
put(channel, action)

// ждёт работу редьюсеров
// и получит ошибку, если она
// произошла при обработке
put.resolve(action)
    

Redux Saga - API # call


call(fn, ...args)
call([context, fn], ...args)
call([context, fnName], ...args)
// fn: Generator, Async Function, Function
    

Redux Saga - API # apply


apply(context, fn, [args])
// alias - call([context, fn], ...args)
    

Redux Saga - API # cps


// call для асинхронных методов в стиле Node.js
cps(fn, ...args)
cps([context, fn], ...args)
    

Redux Saga - API # fork


// не блокирующий запуск саги привязанной к родителю
fork(fn, ...args)
fork([context, fn], ...args)
    

Redux Saga - API # spawn / join


// не блокирующий запуск саги без привязки
spawn(fn, ...args)
spawn([context, fn], ...args)

// привязка
join(task)
join(...tasks)
    

Redux Saga - API # cancel


cancel(task)
cancel(...tasks)
cancel()
    

Redux Saga - API # cancel


import { CANCEL } from 'redux-saga'
import { fork, cancel } from 'redux-saga/effects'

function myApi() {
  const promise = myXhr(...)

  promise[CANCEL] = () => myXhr.abort()
  return promise
}

function* mySaga() {
  const task = yield fork(myApi)

  yield cancel(task)
}
    

Redux Saga - API # select


select(selector, ...args)
// selector(getState(), ...args)
    

Redux Saga - API # actionChannel


actionChannel(pattern, [buffer])
    

Redux Saga - API # actionChannel


import { actionChannel, call } from 'redux-saga/effects'
import api from '...'

function* takeOneAtMost() {
  const chan = yield actionChannel('USER_REQUEST')
  while (true) {
    const {payload} = yield take(chan)
    yield call(api.getUser, payload)
  }
}
    

Redux Saga - API # flush


// стереть все события из буфера
flush(channel)
    

Redux Saga - API # flush


function* saga() {
  const chan = yield actionChannel('ACTION')

  try {
    while (true) {
      const action = yield take(chan)
      // ...
    }
  } finally {
    const actions = yield flush(chan)
    // ...
  }
}
    

Redux Saga - API # cancelled


cancelled()
    

Redux Saga - API # cancelled


function* saga() {
  try {
    // ...
  } finally {
    if (yield cancelled()) {
      // logic that should execute only on Cancellation
    }
    // logic that should execute in all situations (e.g. closing a channel)
  }
}
    

Redux Saga - API # context


setContext(props)
getContext(prop)
    

Redux Saga - API # race


race(effects)
race([...effects])
    

Redux Saga - API # all


all(effects)
all([...effects])
    

Redux Saga - IRL # Suggestions


import { delay } from 'redux-saga';
import { put, call, select } from 'redux-saga/effects';

import { getSuggestions } from '../api';
import PlacesActions from '../actions/places';

export default function* (action) {
  ...
};
    

Redux Saga - IRL # Suggestions


const state = yield select();

if (!state.maps.isApiLoaded) {
  return;
}

if (!action.payload) {
  yield put(PlacesActions.suggestions([]));
  yield put(PlacesActions.select(undefined));
  return;
}

yield call(delay, 250);

...
    

Redux Saga - IRL # Suggestions


...

const suggestions = yield call(
  getSuggestions,
  state.maps.autocompleteService,
  state.countryCode,
  action.payload
);

yield put(PlacesActions.suggestions(
  suggestions || []
));
    

Redux Saga - IRL # Suggestions


import { takeEvery, takeLatest, fork } from 'redux-saga/effects';

import PlacesActions from '../actions/places';
import PlacesSuggestionsSaga from './places-suggestions';

export default function* sagas() {
  yield takeLatest(
    PlacesActions.changeQuery,
    PlacesSuggestionsSaga
  );
}
    

Redux Saga - IRL # Window Event Notifier


import { eventChannel } from 'redux-saga';
import { take, put, race } from 'redux-saga/effects';

import { ComponentActions } from '../../actions/component';

export function notifyAboutWindowEventFactory(
  events,
  action
) {
  let onWindowEvent;
  let destroyed = false;

  if (!(events instanceof Array)) {
    events = [events];
  }

  ...
}
    

Redux Saga - IRL # Window Event Notifier


...
function windowEventChannel() {
  return eventChannel(emitter => {
    onWindowEvent = event => emitter(event);

    events.forEach(eventName =>
      window.addEventListener(
        eventName, onWindowEvent
      )
    );

    return () => {
      events.forEach(eventName => window.removeEventListener(eventName, onWindowEvent));
    };
  });
}
...
    

Redux Saga - IRL # Window Event Notifier


...
return function* () {
  yield take(ComponentActions.init);

  const channel = yield windowEventChannel();
  ...
}
    

Redux Saga - IRL # Window Event Notifier


...
return function* () {
  ...
  while (!destroyed) {
    const { event } = yield race({
      event: take(channel),
      destroy: take(ComponentActions.destroy),
    });

    if (event) {
      yield put(action(event));
    } else {
      channel.close();
      destroyed = true;
    }
  }
}
    

Redux Saga - IRL # Notifications on Blur


function requestPermission() {
  return eventChannel(emitter => {
    Notification.requestPermission((permission) => {
      emitter(permission);
      emitter(END);
    });

    return () => {};
  });
}
    

Redux Saga - IRL # Notifications on Blur


function onClick(notification) {
  return eventChannel(emitter => {
    notification.onclick = () => {
      emitter('clicked!');
      emitter(END);
    };

    return () => {
      notification.onclick = null;
    };
  });
}
    

Redux Saga - IRL # Notifications on Blur


function* notificationsSaga(action) {
  const state = yield select();

  if (state.window.inFocus) {
    return;
  }

  let channel = yield call(requestPermission);
  const permission = yield take(channel);

  if (permission !== 'granted') {
    return;
  }

  ...
}
    

Redux Saga - IRL # Notifications on Blur


function* notificationsSaga(action) {
  ...

  const notification = new Notification(
    `You have ${action.payload.jobs.length} new job posts`,
    { icon: 'assets/new-jobs.png' }
  );

  channel = yield call(onClick, notification);

  const {click, timeout} = yield race({
    click: take(channel),
    timeout: call(delay, 5000)
  });

  ...
}
    

Redux Saga - IRL # Notifications on Blur


function* notificationsSaga(action) {
  ...

  if (click) {
    yield put(JobsActions.show());
    window.focus();
    window.scrollTo(0,0);
  }

  notification.close();
}
    

Redux Saga - IRL # Web Sockets


import { eventChannel, buffers } from 'redux-saga';
import { take, call, put } from 'redux-saga/effects';

import Api from '../api';
import JobsActions from '../actions/jobs';

function readFreshJobs() {
  ...
}

export default function* saga() {
  ...
}
    

Redux Saga - IRL # Web Sockets


// readFreshJobs() {
return eventChannel(emitter => {
  const emit = (jobs) => {
    emitter(jobs);
  };

  Api.socket.on(Api.events.jobsFresh, emit);

  return () => Api.socket.removeListener(
    Api.events.jobsFresh,
    emit
  );
}, buffers.fixed());
    

Redux Saga - IRL # Web Sockets


// saga
const channel = yield call(readFreshJobs);

while (true) {
  let jobs = yield take(channel);

  yield put(JobsActions.fresh(jobs));
}
    

Redux Saga - IRL # Global Event Bus


import { eventChannel } from 'redux-saga';
import { take, put, fork, race } from 'redux-saga/effects';
import EventEmmiter from 'events'

import ComponentActions
  from '../../shared/actions/component';

...
    

Redux Saga - IRL # Global Event Bus


...

if (!window.GlobalEventBus) {
  window.GlobalEventBus = new EventEmmiter();
}

let handler, bus = window.GlobalEventBus,
  destroyed = false;

function globalEventChannel() {
  ...
}

export const globalEventBusSagaFactory
  = function (allowedActions) {
    return function * () {...}
  };
    

Redux Saga - IRL # Global Event Bus


// globalEventChannel
return eventChannel(emitter => {
  handler = event => emitter({
    ...event,
    external: true
  });

  bus.on('global.event', handler);

  return () => {
    bus.removeListener('global.event', handler);
  };
});
    

Redux Saga - IRL # Global Event Bus


// globalEventBusSagaFactory
yield take(ComponentActions.init);

const channel = yield globalEventChannel();

while (!destroyed) {
  const {
    local,
    external,
    destroy
  } = yield race({
    destroy: take(ComponentActions.destroy),
    external: take(channel),
    local: take(),
  });

  ...
    

Redux Saga - IRL # Global Event Bus


// globalEventBusSagaFactory
  ...

  if (external
      && allowedActions.some(action =>
          action === external.type)) {
    yield put(external);
  }

  if (local && !local.external && allowedActions.some(action => action === local.type)) {
    bus.emit('global.event', local);
  }

  if (destroy) {
    bus.removeListener('global.event', handler);
    destroyed = true;
  }
}