Sagi from life

    Good day.


    Do you also have a familiar react-developer who tells wonderful stories about side effects in redux? Not?! Can I become this person?



    The author took the liberty not to write an introductory part about what the library of redux saga is. He hopes that in case of insufficient data, the generous reader will use the Habr search or the official tutorial . The examples are largely simplified to convey the essence.


    So, for what I have collected all of you. It will be about the use of redux saga in the open spaces of combat customers. More specifically, the cases are more complex and interesting than "take action => send an API request => create a new action". 


    I hope to stimulate a deeper study of this library by fellow citizens, and also to share the pleasure of how intricate asynchronous things become clearer and more expressive.


    WebSockets


    Use case: receiving updates of the list of available vacancies from the server in real time using the push model.


    This is of course the use of web sockets. For example, let's take socket.io, but in fact the socket API doesn't matter here.


    In the sagas there is such a thing as a channel. This is a message bus through which the source of events can communicate with their consumer. The main purpose of the channels is to communicate between sagas and convert the stream of asynchronous events into an easy-to-work structure. 


    By default, store is the main event channel for redux saga. Events come in the form of action. Channels are used to work with events not from the store.


    It turns out the channel is just what you need to work with an asynchronous stream of messages from a socket. Let's create a channel as soon as possible!


    But first create a socket:


    import io from 'socket.io-client';
    export const socket = io.connect('/');

    And now we will announce a modest list of events:


    export const SocketEvents = {
      jobsFresh: 'jobs+fresh',
    };

    Next is the factory method for creating a channel. The code creates a method for subscribing to events of interest from the socket, a method for unsubscribing and, directly, the event channel itself:


    import { eventChannel } from 'redux-saga';
    import { socket } from '../apis/socket';
    import { SocketEvents } from '../constants/socket-events';
    export function createFreshJobsChannel() {
      const subscribe = emitter => {    
        socket.on(SocketEvents.jobsFresh, emitter);
        return () => socket.removeListener(SocketEvents.jobsFresh, emitter);
      };
      return eventChannel(subscribe);
    }

    Let's write a fairly simple saga, waiting for updates from the socket and transforming them into the appropriate action:


    import { take, call, put } from 'redux-saga/effects';
    import { createFreshJobsChannel } from '../channels/fresh-jobs';
    import { JobsActions } from '../actions/jobs';
    export function * freshJobsSaga() {
      const channel = yield call(createFreshJobsChannel);
      while (true) {
        const jobs = yield take(channel);
        const action = JobsActions.fresh(jobs);
        yield put(action);
      }
    }

    It remains only to tie it to the root of the saga:


    import { fork } from 'redux-saga/effects';
    import { freshJobsSaga } from './fresh-jobs';
    export function * sagas() {
      yield fork(freshJobsSaga);
    }

    Google Places Autocomplete


    Use case: show prompts when a user enters a geographic location for a subsequent search for real estate nearby.


    In fact, we need coordinates, and the user is the human-readable name of the desired area.


    It would seem, how does this task differ from the boring "action => API => action"? In the case of autocompletion, we want to make as few useless calls to external resources as possible, and also to show the user only actual prompts.


    First, let's write an API method that recycles Google Places Autocomplete Service. From interesting here - restriction of prompts within a given country:


    export function getPlaceSuggestions(autocompleteService, countryCode, query) {
      return new Promise(resolve => {
        autocompleteService.getPlacePredictions({
          componentRestrictions: { country: countryCode },
          input: query,
        }, resolve);
      });
    }

    There is an API method that we pull, you can start writing the saga. It is time to clarify for useless requests.


    Implementation in the forehead, when the user types the text, and we for every change, read - for each character, send a request to the API - leads to nowhere. While the user types it does not need hints. But when he stops - it's time to serve him.


    Also, we would not want to be in a situation where a user typed something in us, stopped, the API request went away, the user added something, another request left.


    Thus, we have created a race between the two requests. Events can develop in two ways, and both are not very pleasant.


    For example, an irrelevant request to complete before the current one and for a moment the user will see irrelevant prompts. Unpleasant, but not critical.


    Or the actual request will end earlier than the irrelevant one and after blinking the user will remain with the same irrelevant prompts. This is already critical.


    Of course, we are not the first to encounter such a problem and the technique known as debounce will help us - the execution of a task no more than N units of time. Here is a little material about this.


    In redux saga, this technique is implemented using two effects - delay and takeLatest . The first postpones the execution of the saga for a specified number of milliseconds. The second interrupts the execution of an already running saga when a new event arrives.


    Knowing all this we will write a saga:


    import { delay } from 'redux-saga';
    import { put, call, select } from 'redux-saga/effects';
    import { PlaceActions } from '../actions/place';
    import { MapsActions } from '../actions/maps';
    import { getPlaceSuggestions } from '../api/get-place-suggestions';
    export function placeSuggestionsSaga * ({ payload: query }) {
      const { maps: { isApiLoaded } } = yield select();
      // если API гугл карт не загрузилось, 
      // то ждём события его загрузки
      if (!isApiLoaded) {
          yield take(MapsActions.apiLoaded);
      }
      // получаем код страны и Google Places Autocomplete из store
      const { maps: { autocompleteService }, countryCode } = yield select();
      // если пользователь всё стёр, 
      // то удаляем подсказки и выбранное ранее значение
      if (query) {
        yield put(PlaceActions.suggestions([]));
        yield put(PlaceActions.select(null));
        return;
      }
      // даём 250мс на допечатку запроса
      yield call(delay, 250);
      // вызываем API метод
      const suggestions = yield call(
        getPlaceSuggestions,
        autocompleteService,
        countryCode,
        query,
      );
      // создаём action с подсказками 
      const action = PlacesActions.suggestions(suggestions || []);
      // и посылаем его в store
      yield put(action);
    };

    As in the previous example, it remains only to tie it to the root saga:


    import { takeLatest } from 'redux-saga/effects';
    import { PlaceActions } from '../actions/place';
    import { placeSuggestionsSaga } from './place-suggestions';
    export function * sagas() {
      yield takeLatest(
        PlaceActions.changeQuery,
        placeSuggestionsSaga,
      );
    }


    Use case: close samopisnyh drop-down lists when clicking outside the control area.


    In fact, it is an emulation of the behavior of the browser’s built-in select. The reasons why you might need a drop-down list written in divs will be left to the reader’s imagination.


    The key feature of the problem being solved is passing an event outside the control, for example, when clicking outside the list.


    Guess? Yes, channels will help us here too. With their help, we will turn the click events that pop up to the very top into the corresponding action.


    It would be nice to have a factory method that creates channels for an arbitrary window event. And here he is:


    import { eventChannel } from 'redux-saga';
    export function createWindowEventChannel(eventName) {
      const subscribe = emitter => {
        window.addEventListener(eventName, emitter);
        return () => window.removeEventListener(eventName, emitter);
      };
      return eventChannel(subscribe);
    }

    Create a very similar saga to the first example (if you wish, you can create a factory method for them):


    import { take, put, call } from 'redux-saga/effects';
    import { createWindowEventChannel } from '../channels/window-event';
    import { DropdownActions } from '../actions/dropdown';
    export function * closeDropdownsSaga() {
      const channel = yield call(createWindowEventChannel, 'onClick');
      while (true) {
        const event = yield take(channel);
        const action = DropdownActions.closeAll(event);
        yield put(action(event));
      }
    }

    Interested reducers will transfer the control to a closed state:


    import { handleActions } from 'redux-actions';
    import { DropdownActions } from '../actions/dropdown';
    export const priceReducer = handleActions({
      ...,
      [DropdownActions.closeAll]: state => ({ ...state, isOpen: false}), 
    }, {});

    The drop-down list itself should stop the distribution of the click event on any internal parts and independently send the close event to the store. For example, when you click to open:


    // components/dropdown.js
    import React from 'react';
    export class Dropdown extends React.Component {
      ...
      __open(event) {
        event.stopPropagation();
        this.props.open();
      }
    }
    // dispatchers/open-price-dropdown.js
    import { DropdownActions } from '../actions/dropdown';
    import { PriceActions } from '../actions/price';
    export const openPriceDropdownDispatcher = dispatch => () => {
      dispatch( DropdownActions.closeAll() );
      dispatch( PriceActions.open() );
    };

    Otherwise, the list simply will not open. The same applies to clicks when choosing an option.


    El clasico, mount the saga:


    import { fork } from 'redux-saga/effects';
    import { closeDropdownsSaga } from './close-dropdowns';
    export function * sagas() {
      yield fork(closeDropdownsSaga);
    }

    Notifications


    Use case: showing browser notifications about the availability of new vacancies, in case the tab is in the background.


    In the active tab, the user will see a change in the special control and therefore notifications are not relevant. But for the background tab can be useful. Of course, with the permission of the user!


    I would also like to click on the notification to go to the tab and show new vacancies. If the user does not respond, then close the notification. For this we need another useful effect - race . It allows you to arrange a race between several other effects. In most cases, a race is used to provide a timeout for any operation.


    Omit the code, tracking tab activity, because of the identity with the click capture code from the previous example.


    Let's write a factory method that will create a channel for requesting approval from the user to receive notifications:


    import { eventChannel, END } from 'redux-saga';
    export function createRequestNotificationPermissionChannel() {
      const subscribe = emitter => {
        Notification.requestPermission(permission => {
          emitter(permission);
          emitter(END);
        });
        return () => {};
      };
      return eventChannel(subscribe);
    }

    In dogonku one more factory method, but already with the channel for receiving a click on the notification:


    import { eventChannel, END } from 'redux-saga';
    export function createNotificationClickChannel(notification) {
      const subscribe = emitter => {
        notification.onclick = event => { 
          emitter(event);
          emitter(END);
        };
        return () => notification.onclick = null;
      };
      return eventChannel(subscribe);
    }

    Both channels are disposable and the maximum is fired by one event, and then closed.


    Remaining key - the saga with logic. Check whether the tab is active, request permission, create a notification, wait for a click or timeout, show new jobs, make the tab active, and then close the notification:


    import { delay } from 'redux-saga';
    import { call, select, race, take } from 'redux-saga/effects';
    import { createRequestNotificationPermissionChannel } from '../channels/request-notification-permission';
    import { createNotificationClickChannel } from '../channels/notification-click';
    import { JobsActions } from '../actions/jobs';
    export function * notificationsSaga(action) {
      const { inFocus } = yield select();
      if (inFocus) return;
      const permissionChannel = yield call(createRequestNotificationPermissionChannel);
      const permission = yield take(permissionChannel);
      if (permission !== 'granted') return;
      const notification = new Notification(
        `You have ${action.payload.jobs.length} new job posts`,
        { icon: 'assets/new-jobs.png' }
      );
      const clickChannel = yield call(createNotificationClickChannel, notification);
      const { click, timeout } = yield race({
        click: take(clickChannel),
        timeout: call(delay, 5000),
      });
      if (click) {
        yield put(JobsActions.show());
        window.focus();
        window.scrollTo(0, 0);
      }
      notification.close();
    }

    We mount the saga before doing this feature-detection:


    import { takeEvery } from 'redux-saga/effects';
    import { JobsActions } from '../actions/jobs';
    import { notificationsSaga } from './notifications';
    export default function * sagas() {
      if ( 'Notification' in window && Notification.permission !== 'denied' ) {
        yield takeEvery(JobsActions.fresh, notificationsSaga);
      }
    }

    Global event bus


    Use case: transfer the specified category of events between redux stor.


    Such a bus is needed if there are several applications with common data on the page. At the same time, applications can be implemented independently of each other.


    For example, a search string with filters and search results in the form of separate react-applications. When you change filters, you want the results application to know about it, if it is also on the page.


    We use the standard event emitter:


    import EventEmmiter from 'events';
    if (!window.GlobalEventBus) {
      window.GlobalEventBus = new EventEmmiter();
    }
    export const globalEventBus = window.GlobalEventBus;

    Already favorite eventChannel turns a standard emitter into a channel:


    import { eventChannel } from 'redux-saga';
    import { globalEventBus as bus } from '../utils/global-event-bus';
    exports function createGlobalEventBusChannel() {
      const subscribe = emitter => {
        const handler = event => emitter({ ...event, external: true });
        bus.on('global.event', handler);
        return bus.removeListener('global.event', handler);
      };
      return eventChannel(subscribe);
    }

    The saga is simple enough - we create a channel and endlessly accept events, either internal or external. If we receive an internal event, then send to the bus, if external - to the store:


    import { take, put, race, call } from 'redux-saga/effects';
    import { globalEventBus as bus } from '../utils/global-event-bus';
    import { createGlobalEventBusChannel } from '../channels/global-event-bus';
    export function * globalEventBusSaga(allowedActions) {
      allowedActions = allowedActions.map(x => x.toString());
      const channel = yield call(createGlobalEventBusChannel);
      while (true) {
        const { local, external } = yield race({
          local: take(),
          external: take(channel),
        });
        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);
        }
      }
    };

    And the final - mounting the saga with the necessary events:


    import { fork } from 'redux-saga/effects';
    import { globalEventBusSaga } from './global-event-bus';
    import { DropdownsActions } from '../actions/dropdowns';
    import { AreasActions } from '../actions/areas';
    export function * sagas() {
      yield fork(globalEventBusSaga, [
        DropdownsActions.closeAll,
        AreasActions.add,
        AreasActions.remove,
        ...
      ]);
    }



    I hope I managed to show that the sagas make the description of complex side effects easier. Explore the library's API, put it on your cases, write complex patterns for waiting for events, and be happy. See you at JS open spaces!


    Also popular now: