Доброго времени суток, Хабровчане!
Хочу рассказать о том, как я недавно узнал о неких "хуках" в 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/