habrahabr

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

  • вторник, 16 июля 2019 г. в 00:20:51
https://habr.com/ru/post/460083/
  • JavaScript
  • ReactJS


Привет, Хабр! Представляю вашему вниманию перевод статьи "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
Ссылка на оригинал