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