(Мета) программируем Redux

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

Рассмотрим классическую пару action/reduce в redux. action хронически пишут свой для каждого объекта приложения. Хотя как раз действия для большинства объектов в приложении очень стандарны: CRUD (Cre­ate, Read, Update, Delete) и дождаться загрузки. reduc­ers хронически пишут через switch{}.

При всей наглядности и простоте, конструкция не очень — все переменные лежат в одном пространстве, даже если они и не нужны обработчику. К тому же, JavaScript очень хорош для метапрограммирования. А значит, если постараться, можно добиться, чтобы код писал сам себя.

Пишем универсальный обработчик

Для начала напишем и положем в корневую директорию проекта файл DataStatus.jsx, где будут храниться статусы. Это простой аналог enum, каким мы его видели в Java или C#:

const DataStatus = {
    NotStarted: "DataStatus.NotStarted",
    Loading : "DataStatus.Loading",
    Updating : "DataStatus.Updating",
    Deleting : "DataStatus.Deleting",
    Completed : "DataStatus.Completed",
    Failure: "DataStatus.Failure" 
};

export default DataStatus;

Текстовые значения добавлены исключительно для удобства отладки. Если вы напишите вместе них числа — я не против. Теперь создадим action. Я назвал файл actions/crud.js:

const generateHeaders = (getState) => {
    const headers = {"Content-Type": "application/json"};
    const {token} = getState().auth;

    if (token) {
        headers["Authorization"] = `Token ${token}`;
    }
    return headers;
}

const act = actionName => actionName.toUpperCase();

const fetchDispatch = (typeName, url, dispatchFunc, errorText, method = "get", body = {}) => {
  const bodylessMethods = ["get", "head", "delete"],
        returnlessMethods = ["delete"];

  let   fetchOptions = { method };

  if(!bodylessMethods.includes(method)){
    fetchOptions = { ...fetchOptions, body: JSON.stringify(body) };
  }
  return (dispatch, getState) => {
    dispatch(itemLoading(typeName));
    return fetch(url, {
      ...fetchOptions,
      headers: generateHeaders(getState)
    })
    .then(
        response => returnlessMethods.includes(method) ? {results : { ok:response.ok, id:body.id }} : response.json(),
        error => {
          console.error(errorText);
          itemFailure(typeName, errorText);
        }
      )
    .catch(error => {
        console.error(`Wrong response from ${url}. Error message: ${errorText}`);
        itemFailure(typeName, errorText);
        }
     )
    .then(data => data && dispatch(dispatchFunc(typeName, data.results || data)));
  }
}

const itemsAll = (typeName, items) => ({
    type: act(`ALL_${typeName}`),
    items: items
  });

const itemAdd = (typeName, item) => ({
    type: act(`ADD_${typeName}`),
    item
  });

const itemGet = (typeName, item) => ({
    type: act(`GET_${typeName}`),
    item
  });

const itemUpdate = (typeName, item) => ({
    type: act(`UPDATE_${typeName}`),
    item
  });

const itemDrop = (typeName, item) => ({
    type: act(`DROP_${typeName}`),
    item
  });

const itemLoading = (typeName) => ({
    type: act(`LOADING_${typeName}`)
  });

const itemFailure = (typeName, errorText) => ({
    type: act(`FAILURE_${typeName}`),
    error: errorText
  });

const add = (data, typeName, url = null) =>
    fetchDispatch(typeName, url || `/api/${typeName}/`, itemAdd,
      `Не удаётся добавить ${typeName}`,
      'post', data);

const update = (id, data, typeName, url = null) =>
    fetchDispatch(typeName, url || `/api/${typeName}/${id}/`, itemUpdate,
      `Не удаётся обновить ${typeName} c id=${id}`,
      'put', data);

const drop = (id, typeName, url = null) => 
    fetchDispatch(typeName, url || `/api/${typeName}/${id}/`, itemDrop,
      `Не удаётся удалить ${typeName} c id=${id}`,
      'delete', {id: id});

const all = (typeName, filter = {}, url = null) => {
  const filterParams = Object.keys(filter).map(key => `${key}=${filter[key]}`).join('&');
  return fetchDispatch(typeName, `/api/${typeName}/?${filterParams}`, itemsAll,
    `Не удаётся загрузить ${typeName}`);
}

const get = (id, typeName, url = null) =>
  fetchDispatch(typeName, url || `/api/${typeName}/${id}`, itemGet,
    `Не удаётся загрузить ${typeName} c id=${id}`);

export {add, update, drop, all, get};

generateHeaders нужен для аутенфикации. Предполагается, что в глобальном state есть свойство auth, в котором и лежат имя пользователя и его Token.

Потом добавляем его экспорт в actions/index.js.

Теперь создадим reducers/crud.js. Чтобы узнать множественное число от называния типа, он использует библиотеку plu­ral­ize. Поэтому сначала нужно выполнить:

npm i pluralize --save

А уже потом писать в reducers/crud.js:

import pluralize from "pluralize"

import DataStatus from '../DataStatus'

export default function generateReducer(typeName, initialState = []) {
    const   typeNameUpperCase = typeName.toUpperCase(),
            typeNamePlural = pluralize(typeName),
            generateState = (status) => {
                const newState = {};
                newState[`${typeNamePlural}Status`] = status;
                return newState;
            },
            actionFor = (command) => `${command}_${typeNameUpperCase}`;
    return (state=initialState, action) => {
        const actions = {};
        actions[actionFor("ALL")] = (state, action, newState) => {
            newState[typeNamePlural] = action.items;
            return {...state, ...newState};
        }
        actions[actionFor("GET")] = (state, action, newState) => {
            newState[typeName] = action.item;
            return {...state, ...newState};
        }
        actions[actionFor("ADD")] = (state, action, newState) => {
            newState[typeNamePlural] = [
                ...state[typeNamePlural],
                action.item
            ];
            return {...state, ...newState};
        }
        actions[actionFor("UPDATE")] = (state, action, newState) => {
            const   newData = state[typeNamePlural].slice(0),
                    indexToUpdate = newData.findIndex(e => e.id === action.item.id);
            newData[indexToUpdate] = action.item;
            newState[typeNamePlural] = newData;
            return {...state, ...newState};
        }
        actions[actionFor("DROP")] = (state, action, newState) => {
            newState[typeNamePlural] = state[typeNamePlural].filter(e => e.id !== action.item.id);
            return {...state, ...newState};
        }
        actions[actionFor("FAILURE")] = (state, action, newState) => ({
                ...state,
                ...{ ...generateState(DataStatus.Failure), error: action.error }
            });
        actions[actionFor("LOADING")] = (state, action, newState) => ({
                ...state,
                ...generateState(DataStatus.Loading)
            });
        const actionToExec = actions[action.type];
        return actionToExec ? actionToExec(state, action, generateState(DataStatus.Completed)) : state;
    }
}

Переменные успешно изолированы. Конечно, функции не очень чистые — иногда new­State изменяется. Если критично, можно клонировать new­State перед использованием. Как ни странно, это всё.

Применяем к компоненту

Пусть теперь у нас есть некий тип, доступный по адресу /api/element. Чтобы создать reduc­er, дописываем в reducers/index.js

import generateReducer from "./crud";

import DataStatus from '../DataStatus'
//...
const elementReducer = generateReducer("element", {
    status: DataStatus.NotStarted,
    elements: []
});

const appReducer = combineReducers({
    //....
    elementReducer
});

И потом в самом компоненте:

import { crud } from '../../actions';

//.....
class ElementPage extends React.Component {
//....
  render() {
    const { elementsStatus, clinicalresearches, drop } = this.props;
    const { formVisible, currentItem } = this.state;
    const isLoadingCompleted = this.props.status != DataStatus.Completed;

    return(
      <div>
        // ...
        { (isLoadingCompleted) ? (
          // Тут какой-нибудь <Spinner />, пока загружается
        ) : (
          <ElementList items={elements} />
        )}
      //...
      </div>
    );
  }
}


//......
const typeName = "element";

const mapDispatchToProps = dispatch => {
    return {
        all: () => dispatch(crud.all(typeName)),
        add: (data) => dispatch(crud.add(data, typeName)),
        update: (id, data) => dispatch(crud.update(id, data, typeName)),
        drop: (id) => dispatch(crud.drop(id, typeName))
    }
}

Конечно, elementsStatus выглядит не очень изящно. Но это лучше, чем statuselements.

Всё, ничего больше писать не нужно.

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

Не думаю, что я первый, кто это придумал. Но эксперимент интересный. Думаю, это надо выпустить в виде небольшой библиотечки.

В таких вещах — как в шахматах, самый красивый вариант часто находят уже потом, при анализе.