Создание Redux-подобного глобального хранилища, используя React Hooks

Привет, Хабр! Представляю вашему вниманию перевод статьи "Build a Redux-like Global Store Using React Hooks" автора Ramsay.


Давайте представим, что я написал интересное предисловие к этой статье и теперь мы сразу можем перейти к по-настоящему интересным вещам. Если говорить вкратце, то мы будем
использовать useReducer и useContext для создания пользовательского хука React, который обеспечит доступ к глобальному хранилищу, похожему на Redux.


Я не в коем случае не предполагаю, что это решение является полным эквивалентом Redux, потому-что я уверен, что это не так. Говоря "Redux-подобное", я имею ввиду то,
что вы будете обновлять хранилище, используя dispatch и actions, которые будут проводить мутацию над состоянием хранилища и возвращать новую копию мутировавшего состояния.
Если вы никогда не пользовались Redux, просто притворитесь, что не читали этот абзац.


Хуки


Давайте начнем с создания контекста(далее Context) который будет содержать наше состояние(далее state) и функцию диспетчеризации(далее dispatch). Мы так же создадим функцию useStore, которая и будет вести себя, как наш хук.


// store/useStore.js

import React, { createContext, useReducer, useContext } from "react";

// пока оставим это пустым
const initialState = {}

const StoreContext = createContext(initialState);

// useStore будет использоваться в React компонентах для извлечения и мутации состояния
export const useStore = store => {
  const { state, dispatch } = useContext(StoreContext);
  return { state, dispatch };
};

Так как все хранится внутри React Context, нужно создать Provider, который даст
нам объект state и функцию dispatch. Provider находится там, где мы используем useReducer.


// store/useStore.js

...
const StoreContext = createContext(initialState);

export const StoreProvider = ({ children }) => {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <StoreContext.Provider value={{ state, dispatch }}>
      {children}
    </StoreContext.Provider>
  );
};
...

Мы используем useReducer, что бы получить state и dispatch. Собственно, это как раз то, что и делает useReducer. Далее мы передаем state и dispatch  в Provider.
Теперь мы можем обернуть любой компонент React с помощью <Provider/> и этот компонент сможет использовать useStore, что бы взаимодействовать с хранилищем.


Мы еще не создали reducer. Это будет нашим следующим шагом.


// store/useStore.js
...
const StoreContext = createContext(initialState);

// это будет мапингом actions, которые будут инициировать мутации state
const Actions = {};

// reducer вызывается всякий раз, когда action совершается через функцию dispatch
// action.type - это строка, которая соответствует функции в Actions
// мы применяем update к текущему state и возвращаем его новую копию
const reducer = (state, action) => {
  const act = Actions[action.type];
  const update = act(state);
  return { ...state, ...update };
};
...

Я большой фанат разделения actions и state в логические группы, например: вам может быть нужно отслеживать состояние счетчика(классический пример реализации счетчика) или состояние пользователя(зашел ли пользователь в систему или его персональные предпочтения).
В каком-то компоненте вам может понадобиться доступ к обоим этим состояниям, так что идея хранить их в едином глобальном хранилище вполне имеет смысл. Мы можем разделить наши actions в логические группы, такие как userActions и countActions, что сделает управление ими намного проще.


Давайте создадим файлы countActions.js и userActions.js в папке store.


// store/countActions.js

export const countInitialState = {
  count: 0
};

export const countActions = {
  increment: state => ({ count: state.count + 1 }),
  decrement: state => ({ count: state.count - 1 })
};

// store/userActions.js

export const userInitialState = {
  user: {
    loggedIn: false
  }
};

export const userActions = {
  login: state => {
    return { user: { loggedIn: true } };
  },
  logout: state => {
    return { user: { loggedIn: false } };
  }
};

В обоих этих файлах мы экспортируем initialState, потому-что хотим потом объединить их в файле useStore.js в единый объект initialState.
Так же, мы экспортируем объект Actions, который предоставляет функции для мутаций состояния. Заметьте, что мы не возвращаем новый объект состояния, потому-что хотим, что бы это происходило в reducer, в файле useStore.js.


Теперь импортируем это все в useStore.js, что бы получить полную картину.


// store/useStore.js

import React, { createContext, useReducer, useContext } from "react";

import { countInitialState, countActions } from "./countActions";
import { userInitialState, userActions } from "./userActions";

// объединение начальных состояний (initial states)
const initialState = {
  ...countInitialState,
  ...userInitialState
};

const StoreContext = createContext(initialState);

// объединение actions
const Actions = {
  ...userActions,
  ...countActions
};

const reducer = (state, action) => {
  const act = Actions[action.type];
  const update = act(state);
  return { ...state, ...update };
};

export const StoreProvider = ({ children }) => {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <StoreContext.Provider value={{ state, dispatch }}>
      {children}
    </StoreContext.Provider>
  );
};

export const useStore = store => {
  const { state, dispatch } = useContext(StoreContext);
  return { state, dispatch };
};

Мы это сделали! Сделайте круг почета, а когда вернетесь, мы посмотрим, как это все использовать в компоненте.


Добро пожаловать обратно! Я надеюсь, что ваш круг был действительно почетным. Давайте посмотрим на useStore в действии.


Сначала мы можем обернуть наш компонент App в <StoreProvider/>.


// App.js

import React from "react";
import ReactDOM from "react-dom";
import { StoreProvider } from "./store/useStore";
import App from "./App";

function Main() {
  return (
    <StoreProvider>
      <App />
    </StoreProvider>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<Main />, rootElement);

Мы оборачиваем App в StoreProvider, что бы дочерний компонент имел доступ к значению из провайдера(provider). Этим значением является и state, и dispatch.


Теперь, давайте предположим, что у нас есть компонент AppHeader у которого есть кнопка login/logout.


// AppHeader.jsx

import React, {useCallback} from "react";
import { useStore } from "./store/useStore";

const AppHeader = props => {
  const { state, dispatch } = useStore();
  const login = useCallback(() => dispatch({ type: "login" }), [dispatch]);
  const logout = useCallback(() => dispatch({ type: "logout" }), [dispatch]);

  const handleClick = () => {
    loggedIn ? logout() : login();
  }

  return (
    <div>
      <button onClick={handleClick}> {loggedIn ? "Logout" : "Login"}</button>
      <span>{state.user.loggedIn ? "logged in" : "logged out"}</span>
      <span>Counter: {state.count}</span>
    </div>
  );
};

export default AppHeader;

Ссылка на Code Sandbox с полной реализацией


Автор оригинала: Ramsay
Ссылка на оригинал

  • +11
  • 3,1k
  • 7
Поделиться публикацией
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 7

    +3

    Важное отличие "redux-подобного" решения от redux: redux не хранит store в react state. Благодаря этому возможно спокойно читать и менять store вне react компонентов (например, в сервисном слое приложения)

      0
      Вот отличное Redux-подобное хранилище от TJ Holowaychuk — twitter.com/tjholowaychuk/status/957853652483416064
        0

        Не рекомендую использовать такое решение в реальных проектах. Во всяком случае в больших:


        • Нет мемоизации, если над provider-ом что-то есть, то каждый случайный render provider-а приведёт к rerender-у всех consumer-ов. Мелочь, но неприятно, лечится мемоизацией
        • Вы храните store в context-е. По сути вы используете context-механизм React-а для обеспечения реактивности. Да, это самое простое решение, но с таким подходом вы не сможете реализовать useSelector и подобные хуки так, чтобы они были производительными.

        На втором пункте остановлюсь подробнее. Любое изменение Provider.value приводит к рендеру всех consumer-ов. А если consumer-ы рендерят что-нибудь не PureComputed || Memo || ..., то это срабатывает ещё каскадом и на дочерние компоненты. А это большая нагрузка. Много лишней работы.


        Посудите сами, у вас ваш глобальный store слушает множество потребителей. И каждому нужен какой-нибудь свой кусок store. Но rerender они будут получать всегда все. В случае react-redux/connect у вас всегда вызываются все mapStateToProps. Это "болезненно" иногда, но всё же не те масштабы, что в случае вашего useStore.


        И вы никак не сможете реализовать хук производительным образом пока не откажетесь от хранения store-данных в контексте. А если откажетесь — то вам придётся писать свою собственную модель подписок\реактивности. Во всех решениях что я смотрел, так и поступили. Самое интересное вместо реальных store данных возвращает proxy-обёртку, которая детектит все обращения к ней и записывает их, а при следующем рендере сверяет только те поля, которые были изменены в store data.


        Правда можно отказаться от хуков и всегда использовать HoC вроде connect-а. Тогда, кажется, проблем нет.

          0

          Спасибо за комментарий.


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


          Вопрос:
          Эй! У меня есть вопрос. Мне действительно нравится это решение и я реализовал что-то похожее в своем приложении, но я заметил такую вещь. Из-за того, что я использую
          useState в множестве различных компонентов, он вызывается множество раз при загрузке. Думаете ли вы, что это будет серьезным препятствием при масштабировании или есть
          путь реализовать это таким образом, что бы он не вызывался снова и снова для извлечения значений?


          Ответ:
          Привет, Брендон! На работе в продакшене мы переместили dispatch в отдельный контекст и теперь имеем отдельные useDispatch и useStore хуки. Обычно компоненты, которым нужен
          dispatch actions, в основном, не используют само состояние. Этот момент сможет срезать большое количество ререндеров, которые вы наблюдаете.


          Другой опцией для возможного сокращения вызовов может быть создание "hydrate store" action, который будет совершать всю начальную загрузку состояния, которое Вам нужно в
          одном действии. Я не уверен, насколько это возможно в вашем проекте, но мы тоже этим занимаемся.

            0

            Всё равно остаётся вопрос как он реализовал useStore. Если просто вынес его в отдельный контекст, то толку с этого не сильно много :) Т.к. по сути ничего не изменилось.

              0
              А как вам такая реализация github.com/storeon/storeon? В данном случае размещение store в провайдере будет тригерить бесконечные ререндеры или нет?
                0

                Глубоко не копал, но судя по вот этому коду, там внутри используется своя система подписок, а обновление осуществляется за счёт setState(newObject) (forceRerender по сути).


                Ну т.е. как и у всех. Никаких проблем не вижу.

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

        Самое читаемое