У нас есть React для пользовательского интерфейса и Redux для хранения данных. Пользователи видят кнопку, нажимают на нее, происходит действие redux, данные сохраняются в глобальном состоянии redux, и кнопка меняет визуальный стиль. Это похоже только на причину и следствие. Он кажется безжизненным. Такие приложения аналогичны примитивным видам.

Другое дело — кнопка, которая после нажатия ждет секунду, имитируя мыслительный процесс, а потом меняет UI. А потом ждет еще секунду и выключается.
В этой статье я постараюсь объяснить, как добавить мозг в приложение и сделать его умнее. Вы можете назвать это «добавлением души в проект React+Redux». Я называю это «Призрак в реакции».

Описание изображения

Пользователь взаимодействует с компонентами React. Компоненты React вызывают действия. Состояние изменения редуктора в Redux.

Теперь в игру вступает Призрак. Ghost видит, что состояние изменилось и делает какую-то работу и вызывает действия для сохранения результата в Redux. Компоненты React видят, что это состояние изменилось, и показывают пользователю обновленный пользовательский интерфейс.


Шаг 1. Установите реакцию с машинописным текстом

npx create-react-app rg_example --template typescript

Вы можете найти множество учебных пособий по . Итак, переходите к следующему шагу.


Шаг 2. Подготовьте страницу.

Очистим файл App.tsx.

import React, {createContext, useContext, useMemo, useState} from 'react';
import './App.css';

type ContextInterface = {
    state: boolean;
    setState: (state: boolean) => void;
};

const Context = createContext<ContextInterface>({
    state: false,
    setState: (state: boolean) => undefined,
});

const App = () => {
    const [state, setState] = useState(false);
    const contextValue = useMemo(() => ({state, setState}), [state]);
    return <Context.Provider value={contextValue}>
        <Panel />
    </Context.Provider>;
};

const Panel = () => {
    const {state, setState} = useContext(Context);
    return (
        <div className='App'>
            <button onClick={() => {
                setState(!state);
            }} >{state ? 'disable' : 'enable'}</button>
            <span>{state ? 'enabled' : 'disabled'}</span>
        </div>
    );
};

export default App;
Войти в полноэкранный режим

Выйти из полноэкранного режима

Мы добавили кнопку. При нажатии — состояние меняется, а кнопка просто меняет текст.


Шаг 3. Добавление призрака.


const ButtonGhost = () => {
    const {state, setState} = useContext(Context);
    useEffect(() => {
        if (state) {
            const id = setTimeout(() => {
                setState(false);
            }, 1000);
            return () => {
                clearTimeout(id);
            };
        }
    }, [state, setState]);
    return null;
};

const App = () => {
    const [state, setState] = useState(false);
    const contextValue = useMemo(() => ({state, setState}), [state]);
    return <Context.Provider value={contextValue}>
        <Panel />
        <ButtonGhost />
    </Context.Provider>;
};

Войти в полноэкранный режим

Выйти из полноэкранного режима

Вот и все. Если вы запустите этот код, вы уже видели живую панель.

Вы нажимаете «включить», и он меняет состояние на «включено». Через 1 секунду Ghost снова меняет состояние на «отключено».


Шаг 3. Время добавить избыточность.

Прежде чем мы сохраним состояние с помощью React Context и перехватим useState.
Давайте переместим состояние панели в Redux.

Сначала установите пакеты:
npm i redux @reduxjs/toolkit use-store-path

мне не нравится react-redux package, потому что мне не нужны все функции этого пакета. Мне достаточно просто подписаться на изменение состояния. Поэтому я использую use-store-path вместо.

import React, {createContext, useContext, useEffect} from 'react';
import {createSlice, configureStore} from '@reduxjs/toolkit';
import type {PayloadAction} from '@reduxjs/toolkit';
import {getUseStorePath} from 'use-store-path';
import './App.css';

export const stateSlice = createSlice({
    name: 'flag',
    initialState: false,
    reducers: {
        set: (state, action: PayloadAction<boolean>) => action.payload,
    },
});

const store = configureStore({reducer: stateSlice.reducer});

const exStore = {
    ...store,
    useStorePath: getUseStorePath(store),
};

const Context = createContext(exStore);

const App = () => <Context.Provider value={exStore}>
    <Panel />
    <ButtonGhost />
</Context.Provider>;

const Panel = () => {
    const {useStorePath, dispatch} = useContext(Context);
    const flag = useStorePath([]);
    return (
        <div className='App'>
            <button onClick={() => {
                dispatch(stateSlice.actions.set(!flag));
            }} >{flag ? 'disable' : 'enable'}</button>
            <span>{flag ? 'enabled' : 'disabled'}</span>
        </div>
    );
};

const ButtonGhost = () => {
    const {useStorePath, dispatch} = useContext(Context);
    const flag = useStorePath([]);
    useEffect(() => {
        if (flag) {
            const id = setTimeout(() => {
                dispatch(stateSlice.actions.set(false));
            }, 1000);
            return () => {
                clearTimeout(id);
            };
        }
    }, [flag, dispatch]);
    return null;
};

export default App;

Войти в полноэкранный режим

Выйти из полноэкранного режима

Обратите внимание, что в этой статье мы сохраняем только один флаг для упрощения примеров. В реальном проекте у каждого редюсера будет больше значений и действий.


Шаг 4. Динамический редуктор.

Динамический редуктор очень полезен в больших приложениях.
я использую пакет @simprl/dynamic-reducer для этого.

npm i @simprl/dynamic-reducer

Добавьте хук useReducer в контекст приложения.

import {reducer as dynamicReducer} from '@simprl/dynamic-reducer';
import {Reducer} from 'redux';

const {reducer, addReducer} = dynamicReducer();

// before: const store = configureStore({reducer: stateSlice.reducer});
const store = configureStore({reducer});

const useReducer = (name: string, reducer: Reducer) => {
    useEffect(
        () => addReducer(name, reducer, store.dispatch),
        [name, reducer],
    );
};

const exStore = {
    ...store,
    useStorePath: getUseStorePath(store),
    useReducer,
};
Войти в полноэкранный режим

Выйти из полноэкранного режима

Теперь мы можем динамически создавать редюсер в Ghost, используя useReducer. Нам просто нужно каждый раз определять пробел («flag1») при отправке действия и подписываться на хранилище Redux.

const ButtonGhost = () => {
    useReducer('flag1', stateSlice.reducer);
    const {useStorePath, dispatch} = useContext(Context);
    const flag = useStorePath(['flag1']);
    useEffect(() => {
        if (flag) {
            const id = setTimeout(() => {
                dispatch({...stateSlice.actions.set(false), space: 'flag1'});
            }, 1000);
            return () => {
                clearTimeout(id);
            };
        }
    }, [flag, dispatch]);
    return null;
};
Войти в полноэкранный режим

Выйти из полноэкранного режима


Шаг 5. Много пробелов.

Теперь мы можем повторно использовать один и тот же редуктор в двух пространствах («флаг1» и «флаг2»).

const App = () => <Context.Provider value={exStore}>
    <Panel space='flag1' />
    <Panel space='flag2' />
    <ButtonGhost space='flag1' />
    <ButtonGhost space='flag2' />
</Context.Provider>;

type WithSpace = {
    space: string;
};

const Panel = ({space}: WithSpace) => {
    const {useStorePath, dispatch} = useContext(Context);
    const flag = useStorePath([space]);
    return (
        <div className='App'>
            <button onClick={() => {
                dispatch({...stateSlice.actions.set(!flag), space});
            }} >{flag ? 'disable' : 'enable'}</button>
            <span>{flag ? 'enabled' : 'disabled'}</span>
        </div>
    );
};

const ButtonGhost = ({space}: WithSpace) => {
    useReducer(space, stateSlice.reducer);
    const {useStorePath, dispatch} = useContext(Context);
    const flag = useStorePath([space]);
    useEffect(() => {
        if (flag) {
            const id = setTimeout(() => {
                dispatch({...stateSlice.actions.set(false), space});
            }, 1000);
            return () => {
                clearTimeout(id);
            };
        }
    }, [flag, dispatch]);
    return null;
};

export default App;
Войти в полноэкранный режим

Выйти из полноэкранного режима

Button Component и Ghost могут работать с разными пространствами. Таким образом, мы можем повторно использовать кнопку, призрак и редюсер с той же функциональностью, но для другого места в Redux.


Шаг 6. Щелкните обработчик.

Но это еще не все. Вы могли заметить, что мы всегда воссоздаем указатель на функцию щелчка. Это приводит к повторному рендерингу мемоизированного компонента, потому что мы каждый раз добавляем новое свойство.

Обычно это фиксируется useCallback

const Panel = ({space}: WithSpace) => {
    const {useStorePath, dispatch} = useContext(Context);
    const flag = useStorePath([space]);

    const clickHandler = useCallback(() => {
        dispatch({...stateSlice.actions.set(!flag), space});
    }, [space, flag]);

    return (
        <div className='App'>
            <button onClick={clickHandler} >{flag ? 'disable' : 'enable'}</button>
            <span>{flag ? 'enabled' : 'disabled'}</span>
        </div>
    );
};
Войти в полноэкранный режим

Выйти из полноэкранного режима

Но вы можете увидеть свойства «флаг» и «пробел» в депе. Так что у нас все еще есть аналогичная проблема. При каждом клике это свойство «флажка» меняется, и мы снова пересоздаем функцию. На самом деле указатель на обработчик не должен зависеть от свойств flag и space. Но при вызове обработчика нам нужно получить значения этих свойств из последнего рендера.
Я использую хук useConstHandler из пакета use-constant-handler

npm i use-constant-handler

import {useConstHandler} from 'use-constant-handler';

const clickHandler = useConstHandler(() => {
    dispatch({...stateSlice.actions.set(!flag), space});
});
Войти в полноэкранный режим

Выйти из полноэкранного режима

useConstHandler каждый раз возвращает константный указатель на функцию, но контекст этой функции меняется при каждом рендере — значит, все свойства в этой функции всегда актуальны.


Шаг 7. Используйте хук Action.

Обычно в своих проектах я создаю хук для call action.

const useSpaceAction = (
    space: string,
    actionCreator: () => AnyAction
) => useConstHandler(() => {
    store.dispatch({space, ...actionCreator()});
});

const exStore = {
    ...store,
    useStorePath: getUseStorePath(store),
    useReducer,
    useSpaceAction,
};
Войти в полноэкранный режим

Выйти из полноэкранного режима

Теперь компонент панели выглядит так

const Panel = ({space}: WithSpace) => {
    const {useStorePath, useSpaceAction} = useContext(Context);
    const flag = useStorePath([space]);

    const clickHandler = useSpaceAction(space, () => stateSlice.actions.set(!flag));

    return (
        <div className='App'>
            <button onClick={clickHandler} >{flag ? 'disable' : 'enable'}</button>
            <span>{flag ? 'enabled' : 'disabled'}</span>
        </div>
    );
};

Войти в полноэкранный режим

Выйти из полноэкранного режима


Шаг 8. Разделение на UI и бизнес-логику.

Хорошей практикой является разделение пользовательского интерфейса и бизнес-логики. Давай сделаем это.

const App = () => <Context.Provider value={exStore}>
    <div className='App'>
        <AppUi />
    </div>
    <AppGhost/>
</Context.Provider>;
Войти в полноэкранный режим

Выйти из полноэкранного режима

Теперь у нас есть два слоя приложения:

  • AppUi для пользовательского интерфейса
  • AppGhost для бизнес-логики

Давайте реализуем их и добавим больше интерактивности

const AppGhost = () => {
    const {useStorePath} = useContext(Context);
    const flag1 = useStorePath(['flag1']);
    return <>
        <ButtonGhost space='flag1' />
        {flag1 && <ButtonGhost space='flag2' />}
    </>;
};

const AppUi = () => {
    const {useStorePath} = useContext(Context);
    const flag1 = useStorePath(['flag1']);
    return <>
        <Panel space='flag1' />
        {flag1 && <Panel space='flag2' />}
    </>;
};
Войти в полноэкранный режим

Выйти из полноэкранного режима

Вторая панель и призрак рендерятся только если flag1 === true.

Обратите внимание, что это также пример преимущества динамического редуктора. Редуктор для флага2 будет добавляться, только если флаг1 равен true, и будет удаляться, когда флаг1 === ложь


Шаг 9. Не используйте JSX для бизнес-логики.

Использование JSX выглядит странно для бизнес-логики. Мы можем запутаться, если будем использовать JSX в обоих случаях. Нам нужно как-то отличать код бизнес-логики от кода пользовательского интерфейса. Я предлагаю использовать ключевые слова «призрак» и «призраки».

import { createElement, Fragment } from 'react';

const ghost = createElement;
const ghosts = (...children) => createElement(Fragment, null, ...children);
Войти в полноэкранный режим

Выйти из полноэкранного режима

Чтобы стандартизировать это, я использую тот же пакет react-ghost во всех моих проектах.

npm i react-ghost

Давайте перепишем AppGhost без jsx

import {ghost, ghosts} from 'react-ghost';

const AppGhost = () => {
    const {useStorePath} = useContext(Context);
    const flag1 = useStorePath<boolean>(['flag1']);
    return ghosts(
        ghost(ButtonGhost, {space: 'flag1'}),
        flag1 && ghost(ButtonGhost, {space: 'flag2'}),
    );
};
Войти в полноэкранный режим

Выйти из полноэкранного режима


Результат

Весь файл:

import React, {createContext, useContext, useEffect} from 'react';
import {createSlice, configureStore, AnyAction} from '@reduxjs/toolkit';
import type {PayloadAction} from '@reduxjs/toolkit';
import {reducer as dynamicReducer} from '@simprl/dynamic-reducer';
import {getUseStorePath} from 'use-store-path';
import './App.css';
import {Reducer} from 'redux';
import {useConstHandler} from 'use-constant-handler';
import {ghost, ghosts} from 'react-ghost';

export const stateSlice = createSlice({
    name: 'flag',
    initialState: false,
    reducers: {
        set: (state, action: PayloadAction<boolean>) => action.payload,
    },
});

const {reducer, addReducer} = dynamicReducer();
const store = configureStore({reducer});
const useReducer = (name: string, reducer: Reducer) => {
    useEffect(
        () => addReducer(name, reducer, store.dispatch),
        [name, reducer],
    );
};

const useSpaceAction = (space: string, actionCreator: () => AnyAction) => useConstHandler(() => {
    store.dispatch({space, ...actionCreator()});
});

const exStore = {
    ...store,
    useStorePath: getUseStorePath(store),
    useReducer,
    useSpaceAction,
};

const Context = createContext(exStore);

const App = () => <Context.Provider value={exStore}>
    <div className='App'>
        <AppUi />
    </div>
    <AppGhost/>
</Context.Provider>;

type WithSpace = {
    space: string;
};

const Panel = ({space}: WithSpace) => {
    const {useStorePath, useSpaceAction} = useContext(Context);
    const flag = useStorePath([space]);

    const clickHandler = useSpaceAction(space, () => stateSlice.actions.set(!flag));

    return (
        <div>
            <button onClick={clickHandler} >{flag ? 'disable' : 'enable'}</button>
            <span>{flag ? 'enabled' : 'disabled'}</span>
        </div>
    );
};

const ButtonGhost = ({space}: WithSpace) => {
    useReducer(space, stateSlice.reducer);
    const {useStorePath, dispatch} = useContext(Context);
    const flag = useStorePath([space]);
    useEffect(() => {
        if (!flag) {
            const id = setTimeout(() => {
                dispatch({...stateSlice.actions.set(true), space});
            }, 1000);
            return () => {
                clearTimeout(id);
            };
        }
    }, [flag, dispatch]);
    return null;
};

const AppUi = () => {
    const {useStorePath} = useContext(Context);
    const flag1 = useStorePath(['flag1']);
    return <>
        <Panel space='flag1' />
        {flag1 && <Panel space='flag2' />}
    </>;
};

const AppGhost = () => {
    const {useStorePath} = useContext(Context);
    const flag1 = useStorePath<string>(['flag1']);
    return ghosts(
        ghost(ButtonGhost, {space: 'flag1'}),
        flag1 && ghost(ButtonGhost, {space: 'flag2'}),
    );
};

export default App;
Войти в полноэкранный режим

Выйти из полноэкранного режима

Вы можете поиграть с этим в codeandbox:


Исходный код

Исходный код доступен на гитхаб
Вы можете проверить каждый шаг в история коммитов


Часто задаваемые вопросы


Почему бы вам просто не использовать промежуточное ПО Redux (ваше собственное, Thunk или Saga)?

Промежуточное ПО не имеет хуков.


Почему бы тебе просто не использовать хуки вместо призрака?

Хуки не могут иметь условий. Вы не можете сделать динамическую композицию хуков. Так что пока не проблема — можно использовать хуки.