React-redux замечательная штука. При правильном использовании архитектура приложения эффективна, а структура проекта и легко читаемая. Но как и в любом решении есть свои особенности.

Описание действий и редьюсеров одна из таких особенностей. Классическая реализация двух этих сущностей в коде довольно трудоемкое занятие.

Боль классической реализации


Простой пример:

// actionTypes.js
// описываем типы действий
export const POPUP_OPEN_START = 'POPUP_OPEN_START ';
export const POPUP_OPEN_PENDING = 'POPUP_OPEN_PENDING ';
export const POPUP_OPEN_SUCCESS = 'POPUP_OPEN_SUCCESS ';
export const POPUP_OPEN_FAIL = 'POPUP_OPEN_FAIL';

export const POPUP_CLOSE_START = 'POPUP_CLOSE_START ';
export const POPUP_CLOSE_PENDING = 'POPUP_CLOSE_PENDING ';
export const POPUP_CLOSE_SUCCESS = 'POPUP_CLOSE_SUCCESS ';
export const POPUP_CLOSE_FAIL = 'POPUP_CLOSE_FAIL';

// actions.js
// описываем сами действия

import {
  POPUP_OPEN_START,
  POPUP_OPEN_PENDING,
  POPUP_OPEN_SUCCESS,
  POPUP_OPEN_FAIL,
  POPUP_CLOSE_START,
  POPUP_CLOSE_PENDING,
  POPUP_CLOSE_SUCCESS,
  POPUP_CLOSE_FAIL
} from './actionTypes';

export function popupOpenStart(name) {
  return {
    type: POPUP_OPEN_START,
    payload: {
      name
    },
  }
}

export function popupOpenPending(name) {
  return {
    type: POPUP_OPEN_PENDING,
    payload: {
      name
    },
  }
}

export function popupOpenFail(error) {
  return {
    type: POPUP_OPEN_FAIL,
    payload: {
      error,
    },
  }
}

export function popupOpenSuccess(name, data) {
  return {
    type: POPUP_OPEN_SUCCESS,
    payload: {
      name,
      data
    },
  }
}

export function popupCloseStart(name) {
  return {
    type: POPUP_CLOSE_START,
    payload: {
      name
    },
  }
}

export function popupClosePending(name) {
  return {
    type: POPUP_CLOSE_PENDING,
    payload: {
      name
    },
  }
}

export function popupCloseFail(error) {
  return {
    type: POPUP_CLOSE_FAIL,
    payload: {
      error,
    },
  }
}

export function popupCloseSuccess(name) {
  return {
    type: POPUP_CLOSE_SUCCESS,
    payload: {
      name
    },
  }
}

// reducers.js
// реализуем редьюсеры

import {
  POPUP_OPEN_START,
  POPUP_OPEN_PENDING,
  POPUP_OPEN_SUCCESS,
  POPUP_OPEN_FAIL,
  POPUP_CLOSE_START,
  POPUP_CLOSE_PENDING,
  POPUP_CLOSE_SUCCESS,
  POPUP_CLOSE_FAIL
} from './actionTypes';

const initialState = {
  opened: []
};

export function popupReducer(state = initialState, action) {
  switch (action.type) {
    case POPUP_OPEN_START:
    case POPUP_OPEN_PENDING:
    case POPUP_CLOSE_START:
    case POPUP_CLOSE_PENDING:
      return {
        ...state,
        error: null,
        loading: true
      };

    case POPUP_OPEN_SUCCESS :
       return {
        ...state,
        loading: false,
        opened: [
          ...(state.opened || []).filter(x => x.name !== action.payload.name),
          {
             ...action.payload
          }
        ]
      };
    case POPUP_OPEN_FAIL:
      return {
        ...state,
        loading: false,
        error: action.payload.error
      };
    
    case POPUP_CLOSE_SUCCESS:
       return {
        ...state,
        loading: false,
        opened: [
            ...state.opened.filter(x => x.name !== name)
        ]
      };
    case POPUP_CLOSE_FAIL:
      return {
        ...state,
        loading: false,
        error: action.payload.error
      };
  }

  return state;
}

На выходе имеем 3 файла и как минимум следующие проблемы:

  • «раздувание» кода при простом добавлении новой цепочки действий
  • избыточный импорт констант действий
  • чтение имен констант действий (индивидуально)

Оптимизация


Данный пример можно улучшить с помощью redux-actions.

import { createActions, handleActions, combineActions } from 'redux-actions'

export const actions = createActions({
    popups: {
        open: {
            start: () => ({ loading: true }),
            pending: () => ({ loading: true }),
            fail: (error) => ({ loading: false, error }),
            success: (name, data) => ({ loading: false, name, data }),
        },
        close: {
            start: () => ({ loading: true }),
            pending: () => ({ loading: true }),
            fail: (error) => ({ loading: false, error }),
            success: (name) => ({ loading: false, name }),
        },
    },
}).popups

const initialState = {
    opened: []
};

export const accountsReducer = handleActions({
    [
        combineActions(
            actions.open.start,
            actions.open.pending,
            actions.open.success,
            actions.open.fail,
            actions.close.start,
            actions.close.pending,
            actions.close.success,
            actions.close.fail
        )
    ]: (state, { payload: { loading } }) => ({ ...state, loading }),

    [combineActions(actions.open.fail, actions.close.fail)]: (state, { payload: { error } }) => ({ ...state, error }),

    [actions.open.success]: (state, { payload: { name, data } }) => ({
        ...state,
        error: null,
        opened:
        [
            ...(state.opened || []).filter(x => x.name !== name),
            {
                name, data
            }
        ]
    }),

    [actions.close.success]: (state, { payload: { name } }) => ({
        ...state,
        error: null,
        opened:
        [
            ...state.opened.filter(x => x.name !== name)
        ]
    })
}, initialState)

Уже намного лучше, но совершенству нет предела.

Лечим боль


В поисках более оптимального решения наткнулись на комментарий LestaD habr.com/ru/post/350850/#comment_10706454 и решили попробовать redux-symbiote.
Это позволило убрать лишние сущности и уменьшить количество кода.

Пример выше стал выглядеть вот так:

// symbiotes/popups.js

import { createSymbiote } from 'redux-symbiote';

export const initState = {
  opened: []
};

export const { actions, reducer } = createSymbiote(initialState, {
  popups: {
    open: {
      start: state => ({ ...state, error: null }),
      pending: state => ({ ...state }),
      success: (state, { name, data } = {}) => ({
        ...state,
        opened: [
            ...(state.opened || []).filter(x => x.name !== name),
            {
              name,
              data
            })
        ]
      }),
      fail: (state, { error } = {}) => ({ ...state, error })
    },
    close: {
      start: state => ({ ...state, error: null }),
      pending: state => ({ ...state }),
      success: (state, { name } = {}) => ({
        ...state,
        opened: [
          ...state.opened.filter(x => x.name !== name)
        ]
      }),
      fail: (state, { error } = {}) => ({ ...state, error })
    }
  }
});


// пример вызова

import {
  actions
} from './symbiotes/popups';

// ...

export default connect(
  mapStateToProps,
  dispatch => ({
    onClick: () => {
        dispatch(actions.open.start({ name: PopupNames.Info }));
    }
  })
)(FooComponent);

Из плюсов имеем:

  • все в одном файле
  • меньше кода
  • структурированное представление действий

Из минусов:

  • IDE не всегда предлагает подсказки
  • сложно искать действие в коде
  • сложно переименовать действие

Не смотря на минусы данный модуль успешно используется в наших проектах.

Спасибо LestaD за хорошую работу.

Комментарии (14)


  1. Finesse
    03.03.2019 05:40

    Как правильно произвести декомпозицию редьюсеров, то есть создать несколько независимых редьюсеров, которые преобразуют независимые состояния, и затем объединить их в один редьюсер? Если создать несколько редьюсеров через redux-symbiote и объединить их через combineReducers, то возникнет риск коллизии названий типов действий (потому что они не записаны в коде явно).


    1. LestaD
      03.03.2019 08:50
      +1

      Спасибо за вопрос. `createSymbiote` имеет третий параметр namespace/options, с помощью которого можно задать префикс для всех экшен-типов сразу.
      github.com/sergeysova/redux-symbiote#options

      const { actions, reducers } = createSymbiote(
        initialState, symbiotes, 'prefix/namespace'
      )
      // or
      const { actions, reducers } = createSymbiote(
        initialState,
        symbiotes,
        { namespace: 'prefix/namespace' },
      )
      


      Некоторые примеры использования symbiote можно посмотреть здесь: github.com/howtocards/frontend/tree/dev/src/features/cards/symbiotes


  1. jakobz
    03.03.2019 09:21

    Мы как-то сделали себе похожий велик на одном проекте на TypeScript. Получилось ну очень похоже:

    const initialState: CounterState = {
        current: 0
    }
    
    const next = ({ }) => (s: MyState) => ({ ...s, current: s.current + 1 });
    const set = (a: { value: number}) => (s: MyState) => ({ ...s, current: s.current + a.value });
    
    const { actionCreators, bindActions, reducer } = buildReducer(
        initialState,
        {
            next,
            set,
        },
        { prefix: "COUNTER" }
    );
    


    В отличии от велика из статьи, тут все строго типизировано. Например, типы actionCreator-ов и их параметров выводятся (мы ради этого карировали функции, action => state => state вместо (action, state) => state).

    Плюс имена экшнов автоматом строятся, и никаких функций в action-ы не упаковывается. Т.е. в нашем случае actionCreators.set({ value: 10 }) => { type: 'COUNTER_SET', value: 10 }.

    Вот тут исходники самой утилитки: gist.github.com/jakobz/ae3e5567e20fff3d66d9e8852a9a655a


  1. rgs350
    03.03.2019 11:36
    +3

    Я позволю себе скопипастить сюда свое сообщение из другой темы:

    Итак имеем:
    — Глобальный стор (обычно все глобальное — плохо, а тут вдруг — хорошо).
    — Кучу экшенов где action.type уникален в пределах приложения.
    — Кучу action creator-ов c названием, в большинстве случаев, таким же как action.type, записанным в другом регистре.
    — reducer-ы — по сути обычный switch case.

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

    Вопрос:
    Где в этом хозяйстве архитектура, абстракции и изоляции и чем оно координально лучше, чем обычные методы хранилища? ЗЫ: Методы хотя бы можно комбинировать в отличии от словоблудия внутри switch case.
    Я так и не нашел ответа на вопрос зачем нужны эти псевдоабстракции.


    1. AlexeyAbretov Автор
      03.03.2019 13:03
      +1

      Спасибо за вопросы.

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

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

      Архитектура redux неплохо описана в документации и легко бьется на слои:
      — стор отдельно, что и как сделать со стором отдельно,
      — представление: глупые компоненты, контейнеры, селекторы данных,
      — для перехвата действий и последующего комбинирования действий над стором (и не только), например, redux-saga.

      Все это можно легко передавать между проектами и тестировать.

      Идеального решения нет, как и ответа на все вопросы.


      1. rgs350
        03.03.2019 13:18
        -1

        Вы не подумайте что это была претензия к вам и вашей статье. Сам использовал похожие решения и велосипедил ради интереса. Они вполне жизнеспособны, особенно если учесть, что react/redux — достаточно популярные библиотеки.


  1. kidar2
    03.03.2019 13:02

    Как было нечитебильное г, так и осталось


  1. DarthVictor
    03.03.2019 13:36
    +1

    Не понятно как на такие действия подписываться в том же redux-saga? У них можно имя получить, через какой-то условный name?

    yield takeEvery(actions.open.success.name, loadAdditional)
    будет работать?


    1. AlexeyAbretov Автор
      03.03.2019 13:41

      yield takeEvery(actions.open.success.toString(), loadAdditional)


      1. DarthVictor
        03.03.2019 14:44
        +1

        Тогда ок. А с типизацией в TypeScript или Flow как обстоят дела, не в курсе?



  1. Aries_ua
    04.03.2019 18:29

    Как вижу подобный код и реализацию — честно, плакать хочется. Ну вот реально вьехать с пол пинка вряд ли получится. Далее все что только можно суем в глобальный стор. Туда жа запихавают логику приложения. Вот попробуйте потом такое приложение оптимизировать. Разбить на слои, где с каждым слоем работала бы команда.

    Почему не вынести бизнес логику получения и обработки данных в отдельные слои? Зачем все держать в сторе, если данные нужны только в одном месте? Как сделать что-то не завязываясь на redux?

    В большинстве туториалов пишут о том, что это серебряная пуля, вот берем и радость. Как сказал один девелопер у меня в команде — «чувак, ты не понимаешь?! Это же React-way! Надо только так писать, так в мануалах пишут». Над… В общем, на практике, это просто добавляет проблем, нежели профита.

    Что делаю у себя в проектах, это в первую очередь разделяю приложение на слои: бизнес логика, API, компоненты, Store.

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

    Как видно из описания, нет проблем разделить работы между людьми. Не жестких привязок. В любой момент можно заменить слой на другую технологию, не переписывая все остальное.


    1. LestaD
      04.03.2019 21:21

      Как потом эти бизнес слои связывать вместе используя Flux?
      Как это дебажить без вменяемых dev-tools?

      redux-symbiote был призван решить только одну проблему: убрать бойлерплейт вокруг экшенов и редюссеров, ни больше, ни меньше.
      symbiote — чисто апдейтеры стора, thunk/execute — бизнес-логика, components — отображение данных. Всё тоже самое разделение на слои, только более простое.
      Разделять приложение между командами нужно полноценно разделяя приложение на micro-frontends, а не работать всем вместе в огромном монолите.

      А вот по поводу качественного разделения: я постепенно перехожу на effector. Где есть и полноценный дебаг и статическое вычисление зависимых сторов, и красивое API, и отстутствие проблемы ромбовидных зависимостей.

      Мб потом статью о нём напишу.