Redux-symbiote - writing actions and reducers with almost no pain

React-redux is a great thing. When used correctly, the application architecture is efficient and the project structure is easy to read. But as in any decision, there are some peculiarities.

Description of actions and reducers is one of such features. The classic implementation of these two entities in code is a rather time-consuming task.

The pain of classic implementation


A simple example:

// 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;
}

The output has 3 files and at least the following problems:

  • "Bloat" the code by simply adding a new chain of actions
  • excess import of action constants
  • reading action constant names (individually)

Optimization


This example can be improved with 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)

Already much better, but there is no limit to perfection.

Treat pain


In search of a better solution, I came across a comment by LestaD habr.com/en/post/350850/#comment_10706454 and decided to try redux-symbiote .
This allowed to remove unnecessary entities and reduce the amount of code.

The example above began to look like this:

// 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);

From the pros we have:

  • all in one file
  • less code
  • structured representation of actions

Of the minuses:

  • IDE doesn't always offer hints
  • difficult to look for action in code
  • difficult to rename action

Despite the cons, this module is successfully used in our projects.

Thanks to LestaD for the good work.

Also popular now: