Redux — Не нужен! Заменяем с помощью useContext и useReducer в React?

в 10:18, , рубрики: front-end, javascript, React, ReactJS, redux

image

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

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

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

Ссылки:

Автор: it-efrem

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js