В этой статье я хотел бы поделиться своим опытом использования связки react-redux и redux-saga, а точнее, какой «велосипед» я использую, для уменьшения количества однотипного кода и упрощению его восприятия.

Что меня не устраивало


Библиотеки react-redux и redux-saga просты, гибки и удобны, однако имеют избыточность кода. Основные элементы это:

  1. Фабрики событий


    const actionCreatorFactory = type => payload => ({ type, payload });
    
    export const INITIALIZE = 'INITIALIZE';
    
    export const ON_FIELD_CHANGE = 'ON_FIELD_CHANGE';
    export const onFieldChange = actionCreatorFactory(ON_FIELD_CHANGE);
    
    export const HANDLE_FIELD = 'HANDLE_FIELD';
    export const handleField = actionCreatorFactory(HANDLE_FIELD);
    
    export const GO_TO_NEXT_STEP = 'GO_TO_NEXT_STEP';
    export const goToNextStep = actionCreatorFactory(GO_TO_NEXT_STEP);
    

    В таком виде меня смущает несколько вещей:

    — описание типов событий. В этом примере можно конечно обойтись и без констант, но все равно придется передавать его тип в фабрику, которое будет идентично имени созданного события в верблюжей(camelCase) нотификации.

    — если вы забыли структуру пайлоада(payload), что бы его вспомнить, надо перейти к reducer/saga, где используется это событие, и посмотреть что там нужно передавать
  2. Редьюсеры


    import getInitialState, {
      formDataInitialState as initialState,
    } from '../helpers/initialState';
    import { HANDLE_FIELD_DONE, ON_FIELD_CHANGE, RESET } from '../actionCreators';
    
    export default (state = initialState, { type, payload }) => {
      switch (type) {
        case RESET: {
          return getInitialState().formDataInitialState;
        }
    
        case ON_FIELD_CHANGE: {
          const { name } = payload;
          return {
            ...state,
            [name]: '',
          }
        }
    
        case HANDLE_FIELD_DONE: {
          const { name, value } = payload;
          return {
            ...state,
            [name]: value,
          }
        }
      }
    
      return state;
    };
    

    Тут в целом напрягает только использование конструкции switch
  3. Саги


    import { all, put, select, fork, takeEvery } from 'redux-saga/effects';
    import { runServerSideValidation } from '../actionCreators';
    import { HANDLE_FIELD } from '../actionCreators';
    
    function* takeHandleFieldAction() {
      yield takeEvery(HANDLE_FIELD, function*({ payload }) {
        const { validation, formData } = yield select(
          ({ validation, formData }) => ({
            validation: validation[payload.name],
            formData,
          })
        );
    
        const valueFromState = formData[payload.name];
    
        if (payload.value !== valueFromState) {
          const { name, value } = payload;
          const { validator } = validation.serverValidator;
          yield put(
            runServerSideValidation({
              name,
              value,
              validator,
              formData,
            })
          );
        }
      });
    }
    
    export default function* rootSaga() {
      yield all([fork(takeHandleFieldAction())]);
    }
    

    В сагах самая большая из проблем, сложность чтения. Просто беглым взглядом понять, что делает сага сложно. Одна из причин этому — глубокая вложенность. Можно конечно вынести воркеры, что бы уменьшить глубину, однако тогда увеличивается избыточность.

    import { all, put, select, fork, takeEvery } from 'redux-saga/effects';
    import { runServerSideValidation } from '../actionCreators';
    import { HANDLE_FIELD } from '../actionCreators';
    
    function* takeHandleFieldWorker({ payload }) {
        const { validation, formData } = yield select(
          ({ validation, formData }) => ({
            validation: validation[payload.name],
            formData,
          })
        );
    
        const valueFromState = formData[payload.name];
    
        if (payload.value !== valueFromState) {
          const { name, value } = payload;
          const { validator } = validation.serverValidator;
          yield put(
            runServerSideValidation({
              name,
              value,
              validator,
              formData,
            })
          );
      }
    }
    
    function* takeHandleFieldWatcher() {
      yield takeEvery(HANDLE_FIELD, takeHandleFieldWorker);
    }
    
    export default function* rootSaga() {
      yield all([fork(takeHandleFieldWatcher())]);
    }
    

    Так же, есть проверки, которые или увеличат вложенность, или добавят точки выхода из саги, что ухудшает наш код.

Как я пытаюсь решить эти проблемы


Давайте по порядку.

  1. Фабрики событий


    import { actionsCreator, payload } from 'sweet-redux-saga';
    
    @actionsCreator()
    class ActionsFactory {
      initialize;
      
      @payload('field', 'value')
      onFieldChange;
      
      @payload('field')
      handleField;
      
      @payload('nextStep')
      goToNextStep;
    }
    

    создаем класс с аннотацией actionsCreator(). При создании экземпляра класса, полям не имеющим значения(initialize;/onFieldChange;/handleField;/gpToNextStep;) будет присвоен привычный нам action creator. Если событие содержит данные, имена полей передаем через аннотацию payload(...[fieldNames]). После преобразования предыдущий пример будет выглядеть вот так:

    class ActionsFactory {
      initialize = () => ({
        type: 'INITIALIZE',
        payload: undefined,
      });
    
      onFieldChange = (field, value) => ({
        type: 'ON_FIELD_CHANGE',
        payload: {
          field,
          value,
        },
      });
    
      handleField = field => ({
        type: 'HANDLE_FIELD',
        payload: {
          field,
        },
      });
    
      goToNextStep = nextStep => ({
        type: 'GO_TO_NEXT_STEP',
        payload: {
          nextStep,
        },
      });
    }
    

    так же у полей будут переопределены методы toString, toPrimitive, valueOf. Они будут возвращать строковое представление типа события:

    const actionsFactory = new ActionsFactory();
    console.log(String(actionsFactory.onFieldChange)); //Вернет 'ON_FIELD_CHANGE'
    
  2. Редьюсеры


    import getInitialState, {
      formDataInitialState as initialState,
    } from '../helpers/initialState';
    import { HANDLE_FIELD_DONE, ON_FIELD_CHANGE, RESET } from '../actionCreators';
    import { reducer } from '../../../leadforms-gen-v2/src/decorators/ReducerDecorator';
    
    @reducer(initialState)
    export class FormDataReducer {
      [RESET]() {
        return getInitialState().formDataInitialState;
      }
    
      [ON_FIELD_CHANGE](state, payload) {
        const { name } = payload;
        return {
          ...state,
          [name]: '',
        };
      }
    
      [HANDLE_FIELD_DONE](state, payload) {
        const { name, value } = payload;
        return {
          ...state,
          [name]: value,
        };
      }
    }
    

    создаем класс с аннотацией reducer([initialState]). При создании экземпляра класса, на выходе получится функция принимающая состояние и экшен, и возвращающая результат обработки экшена.

    function reducer(state = initialState, action) {
      if (!action) {
        return state;
      }
    
      const reducer = instance[action.type];
      if (reducer && typeof reducer === 'function') {
        return reducer(state, action.payload);
      }
    
      return state;
    }
    
  3. Саги


    import { all, put, select, } from 'redux-saga/effects';
    import { runServerSideValidation } from '../actionCreators';
    import { HANDLE_FIELD } from '../actionCreators';
    import { sagas, takeEvery, filterActions } from 'sweet-redux-saga';
    
    @sagas()
    class MySagas {
      @takeEvery([HANDLE_FIELD])
      @filterActions(
        ({state, payload }) => state.formData[payload.name] === payload.value
      )
      * takeHandleFieldAction({ payload }) {
        const { validation, formData } = yield select(({ validation, formData }) => ({
          validation: validation[payload.name],
          formData,
        }));
    
        const { name, value } = payload;
        const { validator } = validation.serverValidator;
        yield put(
          runServerSideValidation({
            name,
            value,
            validator,
            formData,
          })
        );
      }
    }
    
    export default function* rootSaga() {
      yield all([
        new MySagas(),
      ]);
    }
    

    создаем класс с аннотацией sagas(). При создании экземпляра класса получаем генератор функций, вызывающий все поля класс помеченных аннотацией takeEvery([...[actionTypes]]) или takeLatest([...[actionTypes]]) в отдельном потоке:

    function* mySagas() {
      yield all([fork(mySagas.takeHandleFieldAction())]);
    }
    

    так же с полями можно использовать аннотацию filterActions({ state, type, payload }), в этом случае сага будут вызвана только если функция вернет true.

Заключение


В целом, используя эти простые аннотации, саги стали понятнее и чище, уменьшилось количество кода, необходимое для добавление новой логики. Навигация по проекту стала проще.

Эти аннотация я вынес в пакет sweet-redux-saga. Если есть другие решения, буду рад, если поделитесь со мной.