Хабрахабр

[Из песочницы] Redux — Не нужен! Заменяем с помощью useContext и useReducer в React?

image

Доброго времени суток, Хабровчане!

Появились они относительно недавно, в версии [16. Хочу рассказать о том, как я недавно узнал о неких "хуках" в React. 0] от 6 февраля 2019 года (что по скоростям развития FrontEnd — уже очень давно) 8.

Прочитав документацию я заострил свое внимание на хуке useReducer и сразу же задал себе вопрос: "Эта штука способна полностью заменить Redux!?" потратил несколько вечеров на эксперименты и теперь хочу поделиться результатами и своими выводами.

Для нетерпеливых — сразу выводы

За:

  • Вы можете использовать хуки (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 =
}; 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> )
...

Кстати, на счет обновлений, в данном случае мы только записывали новые данные в reducer, а что если нам придется изменить одно значение в дереве с несколькими уровнями вложенности? Если у нас не большое и простое приложение (что в реальности бывает редко), то можно не использовать type и всегда управлять обновлением 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'ы редко бывают идеально плоскими, так что я бы советовал вообще не пользоваться таким методом и обновлять данные только через экшены. По этому такой метод становится мало где пременим, только для обновления плоских reducer'ов в которых нет вложенности и не нужно обращаться к state для обновления вложенных объектов.

(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]

Буду рад узнать, в комментариях, мнение коллег из фронтендерской части нашего Хабросообщетва!

Ссылки:

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»