
Доброго времени суток, Хабровчане!
Хочу рассказать о том, как я недавно узнал о неких "хуках" в React. Появились они относительно недавно, в версии [16.8.0] от 6 февраля 2019 года (что по скоростям развития FrontEnd — уже очень давно)
Прочитав документацию я заострил свое внимание на хуке useReducer и сразу же задал себе вопрос: "Эта штука способна полностью заменить Redux!?" потратил несколько вечеров на эксперименты и теперь хочу поделиться результатами и своими выводами.
Нужно ли заменять Redux с помощью useContext + useReducer?
Для нетерпеливых — сразу выводы
За:
- Вы можете использовать хуки (useContext + useReducer) вместо Redux в не больших приложениях (где нет необходимости в больших комбинированных Reducers). В данном случае Redux действительно может оказаться избыточным.
Против:
- Большое количество кода уже написано на связке React + Redux и переписывать его на хуки (useContext + useReducer) кажется мне не целесообразным, по крайней мере сейчас.
- Redux — проверенная библиотека, хуки — нововведение, их интерфесы и поведение может измениться в дальнейшем.
- Для того чтобы сделать использование useContext + useReducer действительно удобным, придется написать некоторые велосипеды.
Выводы являются личным мнением автора и не претендуют на безусловную истинность — если вы не согласны, буду рад увидеть вашу конструктивную критику в комментариях.
Давайте попробуем разобраться
Начнем с простого примера
(reducer.js)
import React from "react"; export const ContextApp = React.createContext(); export const initialState = { app: { test: 'test_context' } }; export const testReducer = (state, action) => { switch(action.type) { case 'test_update': return { ...state, ...action.payload }; default: return state } };
Пока что наш reducer выглядит точно так же как и в Redux
(app.js)
import React, {useReducer} from 'react' import {ContextApp, initialState, testReducer} from "./reducer.js"; import {IndexComponent} from "./IndexComponent.js" export const App = () => { // Инициализируем reducer и получаем state + dispatch для записи const [state, dispatch] = useReducer(testReducer, initialState); return ( // Для того, чтобы мы могли использовать reducer в компонентах // Воспользуемся ContextApp и передадим (dispatch и state) // в компоненты ниже по иерархии <ContextApp.Provider value={{dispatch, state}}> <IndexComponent/> </ContextApp.Provider> ) };
(IndexComponent.js)
import React, {useContext} from "react"; import {ContextApp} from "./reducer.js"; export function IndexComponent() { // Используем функцию useContext для получения контекста ContextApp // Компонент IndexComponent должен быть обязательно обернут в ContextApp.Provider const {state, dispatch} = useContext(ContextApp); return ( // Используя dispatch мы попадаем в reducer.js в метод testReducer // который и обновляет состояние. Все как в Redux <div onClick={() => {dispatch({ type: 'test_update', payload: { newVar: 123 } })}}> {JSON.stringify(state)} </div> ) }
Это самый простой пример, в котором мы просто обновляем записываем новые данные в плоский (без вложенности) reducer
В теории, даже можно попробовать написать так:
(reducer.js)
... export const testReducer = (state, data) => { return { ...state, ...data } ...
(IndexComponent.js)
... return ( // Теперь мы просто отправляем новые данные, без указания type <div onClick={() => {dispatch({ newVar: 123 }> {JSON.stringify(state)} </div> ) ...
Если у нас не большое и простое приложение (что в реальности бывает редко), то можно не использовать type и всегда управлять обновлением reducer прямо из экшена. Кстати, на счет обновлений, в данном случае мы только записывали новые данные в reducer, а что если нам придется изменить одно значение в дереве с несколькими уровнями вложенности?
Теперь посложнее
Давайте рассмотрим следующий пример:
(IndexComponent.js)
... return ( // Теперь мы хотим обновить данные внутри дерева // для этого нам нужно как-то получить самое актуальное состояние // этого дерева в момент вызова экшена, можно сделать это через callback: <div onClick={() => { // Сделаем так, чтобы экшен возвращал callback, // который внутри testReducer будет передавать самый актуальный state (state) => { const {tree_1} = state; return { tree_1: { ...tree_1, tree_2_1: { ...tree_1.tree_2_1, tree_3_1: 'tree_3_1 UPDATE' }, }, }; }> {JSON.stringify(state)} </div> ) ...
(reducer.js)
... export const initialState = { tree_1: { tree_2_1: { tree_3_1: 'tree_3_1', tree_3_2: 'tree_3_2' }, tree_2_2: { tree_3_3: 'tree_3_3', tree_3_4: 'tree_3_4' } } }; export const testReducer = (state, callback) => { // Теперь нам необходимо получить актуальный state внутри экшена который мы инициируем // мы можем сделать это через callback const action = callback(state); return { ...state, ...action } ...
Окей, с обновлением дерева тоже разобрались. Хотя в таком случае уже лучше вернуться к использованию types внутри testReducer и обновлять дерево по определенному типу экшена. Все как в Redux, только результирующий bundle немного меньше [8].
Асинхронные операции и dispatch
Но так ли все хорошо? Что будет, если мы заходим использовать асинхронные операции?
Для этого нам придется определить собственный dispatch. Давайте попробуем!
(action.js)
export const actions = { sendToServer: function ({dataForServer}) { // Для этого нам придется возвращать функцию, которая принимает dispatch return function (dispatch) { // А внутри dispatch так же возвращать функцию, // которая принимает state как и в предыдущих примерах dispatch(state => { return { pending: true } }); } }
(IndexComponent.js)
const [state, _dispatch] = useReducer(AppReducer, AppInitialState); // Чтобы иметь возможность вызывать dispatch из экшена -> // Нужно его туда передать, напишем Proxy const dispatch = (action) => action(_dispatch); ... dispatch(actions.sendToServer({dataForServer: 'data'})) ...
Вроде тоже все окей, но теперь у нас появляется большая вложенность колбеков, что не очень круто, если мы захотим просто изменить состояние без создания функции-экшена, нам придется написать конструкцию такого вида:
(IndexComponent.js)
... dispatch( (dispatch) => dispatch(state => { return { {dataForServer: 'data'} } }) ) ...
Получается что-то страшное, не так ли? Для простого обвновления данных очень хотелось бы написать нечто подобное:
(IndexComponent.js)
... dispatch({dataForServer: 'data'}) ...
Для этого придется изменить Proxy для функции dispatch, который мы создали ранее
(IndexComponent.js)
const [state, _dispatch] = useReducer(AppReducer, AppInitialState); // Заменяем // const dispatch = (action) => action(_dispatch); // На const dispatch = (action) => { if (typeof action === "function") { action(_dispatch); } else { _dispatch(() => action) } }; ...
Теперь мы можем передавать в dispatch как функцию экшена, так и простой объект.
Но! При простой передаче объекта необходимо быть осторожным, может возникнуть соблазн сделать так:
(IndexComponent.js)
... dispatch({ tree: { // К state у нас имеется доступ из любого компонента внутри AppContext ...state.tree, data: 'newData' } }) ...
Чем плох этот пример? Тем, что к моменту обработки данного dispatch, state мог быть обновлен через другой dispatch, но эти изменения еще не долши до нашего компонента и по сути мы используем старый экземпляр state, который перезапишет все старыми данными.
По этому такой метод становится мало где пременим, только для обновления плоских reducer'ов в которых нет вложенности и не нужно обращаться к state для обновления вложенных объектов. В реальности reducer'ы редко бывают идеально плоскими, так что я бы советовал вообще не пользоваться таким методом и обновлять данные только через экшены.
(action.js)
... // Т.к. в dispatch всегда передается callback, внутри этого колбека // мы всегда имеем самый актуальный стейт (см. reducer.js) dispatch(state => { return { dataFromServer: { ...state.dataFromServer, form_isPending: true } } }); axios({ method: 'post', url: `...`, data: {...} }).then(response => { dispatch(state => { // Даже если axios запрос выполнялся несколько секунд // и в этом промежутке было выполнено еще несколько dispatch // из других мест в коде, этот state - всегда будет самым актуальным, // т.к. мы получаем его на прямую из testReducer (reducer.js) return { dataFromServer: { ...state.dataFromServer, form_isPending: false, form_request: response.data }, user: {} } }); }).catch(error => { dispatch(state => { // Аналогично, state - свеж как утренний фреш) return { dataFromServer: { ...state.dataFromServer, form_isPending: false, form_request: { error: error.response.data } }, } }); ...
Выводы:
- Это был интересный опыт, я укрепил свои академические знания и изучил новые фичи реакта
- Я не стану использовать этот подход в продакшене (по крайней мере в ближайшие полгода). По уже описанным выше причинам (это новая фича, а Redux — проверенный и надежный инструмент) + Я не испытываю проблем с производительностью чтобы гнаться за миллисекундами которые можно выиграть отказавшись от редакса [8]
Буду рад узнать, в комментариях, мнение коллег из фронтендерской части нашего Хабросообщетва!
Ссылки:
- Replacing redux with react hooks and context (part 1)
- When to use native React.useReducer Hook and how it differentiate from Redux
- You Might Not Need Redux (But You Can’t Replace It With Hooks)
- React's useReducer vs Redux
- When Context Replaces Redux
- Встроенная альтернатива Redux с помощью React Context и хуков
- Смогут ли React-хуки заменить Redux?
- [8] https://bespoyasov.ru/blog/you-really-dont-need-redux-now/

