React и Redux вместе. Часть 4 из 7

09.09.2022

Теги: FrontendHookJavaScriptReact.jsWeb-разработкаСостояниеСписокТеорияФункция

Будем создавать приложение списка задач — почти такое же, как в первой части — но уже с использованием Redux. Есть несколько вариантов использования Redux в React-приложении. Пакет redux позволяет работать с классическим Redux. Пакет @reduxjs/toolkit позволяет работать с Redux по-новому. Пакет react-redux обеспечивает взаимодействие компонентов с хранилищем. В четвертой части будем использовать Redux Toolkit и два хука из пакета react-redux.

2. Библиотека Redux Toolkit

Использование библиотеки Redux требует большого количества однотипного кода. Библиотека Redux Toolkit призвана исправить этот недостаток. Как и раньше, надо обернуть компонент верхнего уровня в <Provider>, чтобы все его потомки могли получить доступ к хранилищу. Для взаимодействия React компонентов с хранилищем Redux будем использовать только хуки.

import { Provider } from 'react-redux';
import { store } from './redux/store.js';

function App() {
    return (
        <Provider store={store}>
            <h1>Список задач</h1>
            <TodoList />
        </Provider>
    );
}

Как это работает

Наиболее значимыми функциями, предоставляемыми библиотекой Redux Toolkit являются:

  • configureStore — функция, предназначенная упростить процесс создания и настройки хранилища
  • createReducer — функция, помогающая лаконично и понятно описать и создать редюсер
  • createAction — возвращает функцию создателя действия для заданной строки типа действия
  • createSlice — объединяет в себе функционал createAction и createReducer
  • createSelector — переэкспортированная функция для кэширования из пакета reselect

Функция configureStore

Если мы не используем пакет @reduxjs/toolkit, наш код создания хранилища выглядит примерно так:

import { createStore, applyMiddleware, combineReducers, compose } from 'redux';
import thunk from 'redux-thunk';
import logger from 'redux-logger';
import { todoReducer } from './todoReducer.js';
import { userReducer } from './userReducer.js';

const reduvDevExt = window.__REDUX_DEVTOOLS_EXTENSION__;
const reduxDevTool = reduvDevExt && process.env.NODE_ENV !== 'production' ? reduvDevExt() : f => f;

const store = createStore(
    combineReducers({
        todo: todoReducer,
        user: userReducer,
    }),
    compose(
        applyMiddleware(thunk, logger),
        reduxDevTool
    )
);

Функция configureStore позволяет комбинировать редюсеры, добавлять middleware, а также использовать расширение Redux DevTools. В качестве входного параметра принимает объект со следующими свойствами:

  • reducer — набор пользовательских редюсеров
  • middleware — массив middleware (опционально)
  • devTools — включить расширение Redux DevTools (по умолчанию — true)
  • preloadedState — начальное состояние хранилища (опционально)
  • enhancers — набор усилителей (опционально)

import { configureStore } from '@reduxjs/toolkit';
import thunk from 'redux-thunk';
import logger from 'redux-logger';
import { todoReducer } from './todoReducer.js';
import { userReducer } from './userReducer.js';

export const store = configureStore({
    reducer: { // комбинируем редюсеры
        todo: todoReducer,
        user: userReducer,
    },
    middleware: [thunk, logger], // добавляем middleware
    devTools: process.env.NODE_ENV !== 'production', // расширение Redux DevTools
});

Для получения списка middleware по умолчанию можно воспользоваться специальной функцией getDefaultMiddleware. Перечень этих middleware отличается в зависимости от того, в каком режиме выполняется код. В production режиме массив состоит только из одного элемента thunk. В development режиме список пополняется следующими middleware:

  • serializableStateInvariant — предназначен для проверки состояния на предмет несериализуемых значений
  • immutableStateInvariant — предназначен для обнаружения мутаций данных, содержащихся в хранилище
const store = configureStore({
    reducer: rootReducer,
    // добавить к дефолтным middleware еще один middleware logger
    middleware: getDefaultMiddleware().concat(logger),
})

Функция createAction

При использовании библиотеки Redux мы должны объявить константы типов действий, создать генераторы действий (action creator) — и потом использовать функцию store.dispatch для отправки действий. Генераторы действий нужны для удобства, чтобы не работать напрямую с объектом action, который содержит обязательное поле type и необязетельное поле payload.

export const TODO_CREATE = 'TODO_CREATE';
export const TODO_TOGGLE = 'TODO_TOGGLE';
export const TODO_REMOVE = 'TODO_REMOVE';
export function todoCreate(data) {
    return {
        type: TODO_CREATE,
        payload: data,
    };
}

export function todoToggle(id) {
    return {
        type: TODO_TOGGLE,
        payload: id,
    };
}

export function todoRemove(id) {
    return {
        type: TODO_REMOVE,
        payload: id,
    };
}
// создание новой задачи
store.dispatch(todoCreate(data));
// изменение статуса задачи
store.dispatch(todoToggle(id));
// удаление одной задачи
store.dispatch(todoRemove(id));

Функция createAction позволяет два действия — объявление константы и создание генератора действия — объединить в одно.

export const todoCreate = createAction('TODO_CREATE');
export const todoToggle = createAction('TODO_TOGGLE');
export const todoRemove = createAction('TODO_REMOVE');

На вход createAction принимает тип действия и возвращает генератор действия для этого типа. Генератор действия может быть вызван либо без аргументов, либо с некоторым аргументом (полезная нагрузка), значение которого будет помещено в поле payload созданного действия. У созданных функцией createAction генераторов действий переопределен метод toString, так что тип действия становится их строковым представлением.

// исходный код функции createAction
function createAction(type, prepareAction) {
    function actionCreator(...args) {
        if (prepareAction) {
            let prepared = prepareAction(...args);
            if (!prepared) {
                throw new Error('prepareAction did not return an object');
            }
            return {
                type: type,
                payload: prepared.payload,
            };
        }
        return {
            type: type,
            payload: args[0],
        };
    }
    actionCreator.toString = () => `${type}`;
    actionCreator.type = type;
    actionCreator.match = (action) => action.type === type;
    return actionCreator;
}

Функция createAction может принимать второй аргумент prepareAction. Это функция, которая позволяет подготовить payload, прежде чем он будет использован в редюсере. Например, при создании элемента списка задач, мы получаем только название задачи — а нам нужен объект с полями id, title и completed.

const todoCreate = createAction('TODO_CREATE', function prepare(text) {
    return {
        payload: {
            title: text,
            id: uuid(),
            completed: false,
        },
    }
});
console.log(todoCreate('Новая задача'));
{
    type: 'TODO_CREATE',
    payload: {
        title: 'Новая задача',
        id: '8f3a31ea-53f8-49f7-8a9b-ee6549c2dba1',
        completed: false,
    }
}

Функция createReducer

Редюсеры часто реализуются с помощью оператора switch и операторами case для каждого типа действия. Этот подход работает хорошо, но не лишен бойлерплейта и подвержен ошибкам. Например, легко забыть описать случай default или не установить начальное состояние.

const initState = [];

function todoReducer(state = initState, action) {
    let newState;
    switch (action.type) {
        case TODO_CREATE:
            newState = [...state, action.payload];
            return newState;
        case TODO_TOGGLE:
            newState = state.map(todo => {
                return todo.id === action.payload ? { ...todo, completed: !todo.completed } : todo;
            });
            return newState;
        case TODO_REMOVE:
            newState = state.filter(todo => todo.id !== action.payload);
            return newState;
        default:
            return state;
    }
}

Функция createReducer упрощает создание функции редюсера, определяя их как таблицы поиска мини-функций для обработки каждого типа действия. Она также позволяет существенно упростить логику иммутабельного обновления, написав код в «мутабельном» стиле внутри мини-редюсеров.

const todoCreate = createAction('TODO_CREATE');
const todoToggle = createAction('TODO_TOGGLE');
const todoRemove = createAction('TODO_REMOVE');

const initState = [];

const todoReducer = createReducer(initState, (builder) => {
    builder
        .addCase(todoCreate, (state, action) => { // todoCreate.toString() === 'TODO_CREATE'
            state.push(action.payload); // мутация state
        })
        .addCase(todoToggle, (state, action) => { // todoToggle.toString() === 'TODO_TOGGLE'
            const item = state.find((item) => item.id === action.payload);
            item.completed = !item.completed; // мутация state
        })
        .addCase(todoRemove, (state, action) => { // todoRemove.toString() === 'TODO_REMOVE'
            return state.filter((item) => item.id !== action.payload); // возврат нового state
        })
});

«Мутабельный» стиль обработки событий доступен благодаря использованию библиотеки Immer. Функция обработчик может либо «мутировать» переданный state, либо возвращать новый state. Но, благодаря Immer, реальная мутация объекта не осуществляется.

Возврат нового объекта из функции перекрывает «мутабельные» изменения. Одновременное применение обоих методов обновления состояния не сработает — либо одно, либо другое.

Функция createSlice

Функция createSlice анализирует функции, определенные в поле reducers, и создает функцию-редюсер и генератор действия для каждого случая. Поскольку в этом случае редюсеры и генераторы действия неразрывно связаны друг с другом, предусмотрено поле extraReducers, которое позволяет реагировать на action (как правило, из другого среза), но не создает генератор действия.

В качестве входных параметров принимает объект со следующими полями:

  • name — имя среза (служит префиксом для экшенов, например todo/toggle)
  • initialState — начальное состояние среза состояния
  • reducers — объект с обработчиками экшенов. Каждый обработчик принимает state и action = {type, payload}
  • extraReducers — объект, содержащий редюсеры другого среза, если нужно обновить другой срез

Результатом работы функции является объект, называемый «срез», со следующими полями:

  • name — имя среза состояния
  • reducer — атоматически созданная функция-редюсер, которую можно передать в combineReducers
  • actions — автоматически созданные с помощью createAction генераторы действий
  • caseReducers — те функции, которые мы передали в createSlice через поле reducers
const todoSlice = createSlice({
    name: 'todo',
    initialState: [],
    reducers: {
        create(state, action) {
            // action = { type: 'todo/create', payload: {id: 123, title: 'Новая задача', completed: false} }
            state.push(action.payload); // мутация state
        },
        toggle(state, action) { // action = { type: 'todo/toggle', payload: 123 }
            const item = state.find((item) => item.id === action.payload);
            item.completed = !item.completed; // мутация state
        },
        remove(state, action) { // action = { type: 'todo/remove', payload: 456 }
            return state.filter((item) => item.id !== action.payload); // возврат нового state
        },
    },
});

export const { create, toggle, remove } = todoSlice.actions; // генераторы действий

export default todoSlice.reducer; // редюсер этого среза
import todoReducer from './todoSlice.js';
import userReducer from './userSlice.js';

export const store = configureStore({
    reducer: {
        todo: todoReducer,
        user: userReducer,
    },
});

Если нужно предварительно подготовить payload, прежде чем использовать:

const todoSlice = createSlice({
    name: 'todo',
    initialState: initialState,
    reducers: {
        create: {
            reducer: (state, action) => {
                state.push(action.payload);
            },
            prepare: (text) => {
                return {
                    payload: {
                        id: uuid(),
                        title: text,
                        completed: false,
                    }
                };
            },
        },
        toggle(state, action) {
            const item = state.find((item) => item.id === action.payload);
            item.completed = !item.completed; // мутация state
        },
        remove(state, action) {
            return state.filter((item) => item.id !== action.payload); // возврат нового state
        },
    },
});
Extra reducers

Разделение данных по слайсам приводит к ситуациям, когда на одно действие нужно реагировать в разных частях хранилища. Например, если удаляется пост, то нужно удалить и его комментарии, которые находятся в другом слайсе.

В Redux такая задача решается просто добавлением в switch реакции на нужное действие по его имени. В Redux Toolkit так уже не получится из-за железной связи редюсеров с действиями. Это цена, которую мы платим за сокращение кода.

Для реакции на действия, происходящие в других слайсах, Redux Toolkit добавляет механизм дополнительных редюсеров extraReducers. В слайс добавляется свойство extraReducers, через которое можно устанавливать реакцию (редюсеры) на внешние действия.

// Импортируем из других слайсов действия, на которые нужно реагировать
import { removePost } from './postSlice.js';

const commentSlice = createSlice({
    name: 'comment',
    initialState: [],
    reducers: {
        // обычные редюсеры
    },
    extraReducers: (builder) => {
        // При удалении поста нужно удалить все его комментарии
        builder.addCase(removePost, (state, action) => {
            const postId = action.payload;
            return state.filter((item) => item.postId !== postId);
        });
    },
});
// где-то в приложении...
dispatch(removePost(post.id));

Приложение, первый вариант

Нам потребуется развернуть React приложение с помощью create-react-app + установить два пакета — react-redux и @reduxjs/toolkit.

$ cd react-redux/react-redux-toolkit
$ npx create-react-app .
$ npm install react-redux @reduxjs/toolkit --save-prod
$ npm install uuid re-reselect --save-prod # доп.пакеты

Создаем хранилище

Для начала нам нужно хранилище, для этого создаем директорию src/redux, внутри нее — все необходимое для работы Redux.

Файл src/redux/store.js:

import { configureStore } from '@reduxjs/toolkit';
import { todoReducer } from './todoReducer.js';
import { userReducer } from './userReducer.js';

export const store = configureStore({
    reducer: {
        todo: todoReducer,
        user: userReducer,
    },
    devTools: process.env.NODE_ENV !== 'production',
});

Файл src/redux/todoReducer.js:

import { createReducer } from '@reduxjs/toolkit';
import { todoCreate, todoToggle, todoRemove } from './todoActions.js';

const initState = [
    { id: 1, title: 'Первая задача', completed: false },
    { id: 2, title: 'Вторая задача', completed: true },
    { id: 3, title: 'Третья задача', completed: false },
    { id: 4, title: 'Четвертая задача', completed: true },
    { id: 5, title: 'Пятая задача', completed: false },
];

export const todoReducer = createReducer(initState, (builder) => {
    builder
        .addCase(todoCreate, (state, action) => { // todoCreate.toString() === 'TODO_CREATE'
            state.push(action.payload); // мутация state
        })
        .addCase(todoToggle, (state, action) => { // todoToggle.toString() === 'TODO_TOGGLE'
            const item = state.find((item) => item.id === action.payload);
            item.completed = !item.completed; // мутация state
        })
        .addCase(todoRemove, (state, action) => { // todoRemove.toString() === 'TODO_REMOVE'
            return state.filter((item) => item.id !== action.payload); // возврат нового state
        });
});

Файл src/redux/userReducer.js:

import { createReducer } from '@reduxjs/toolkit';
import { userLogin, userLogout } from './userActions.js';

const initState = {
    auth: false,
};

export const userReducer = createReducer(initState, (builder) => {
    builder
        .addCase(userLogin, (state, action) => { // userLogin.toString() === 'USER_LOGIN'
            state.auth = true;
        })
        .addCase(userLogout, (state, action) => { // userLogout.toString() === 'USER_LOGOUT'
            state.auth = false;
        });
});

Файл src/redux/actions.js:

import { todoCreate, todoToggle, todoRemove } from './todoActions.js';
import { userLogin, userLogout } from './userActions.js';

export const actions = {
    todo: {
        create: todoCreate,
        toggle: todoToggle,
        remove: todoRemove,
    },
    user: {
        login: userLogin,
        logout: userLogout,
    },
};

Файл src/redux/todoActions.js:

import { createAction } from '@reduxjs/toolkit';

export const todoCreate = createAction('TODO_CREATE');
export const todoToggle = createAction('TODO_TOGGLE');
export const todoRemove = createAction('TODO_REMOVE');

Файл src/redux/userActions.js:

import { createAction } from '@reduxjs/toolkit';

export const userLogin = createAction('USER_LOGIN');
export const userLogout = createAction('USER_LOGOUT');

Файл src/redux/selectors.js:

import {
    all,
    allLength,
    completed,
    completedLength,
    uncompleted,
    uncompletedLength,
    findById,
    ids,
} from './todoSelectors.js';
import { auth } from './userSelectors.js';

export const selectors = {
    todo: {
        all: all,
        allLength: allLength,
        completed: completed,
        completedLength: completedLength,
        uncompleted: uncompleted,
        uncompletedLength: uncompletedLength,
        findById: findById,
        ids: ids,
    },
    user: {
        auth: auth,
    },
};

Файл src/redux/todoSelectors.js:

// функцию createSelector теперь можно импортировать из @reduxjs/toolkit
import { createSelector } from '@reduxjs/toolkit';
import { createCachedSelector } from 're-reselect';

export const all = (state) => state.todo; // массив всех задач
export const allLength = createSelector( // количество всех задач
    all,
    (items) => items.length
);
export const completed = createSelector( // массив завершенных задач
    all,
    (items) => items.filter((item) => item.completed)
);
export const completedLength = createSelector( // количество завершенных задач
    completed,
    (items) => items.length
);
export const uncompleted = createSelector( // массив не завершенных задач
    all,
    (items) => items.filter((item) => !item.completed)
);
export const uncompletedLength = createSelector( // количество не завершенных задач
    uncompleted,
    (items) => items.length
);
export const ids = createSelector( // идентификаторы всех задач
    all,
    (items) => items.map((item) => item.id)
);
export const findById = createCachedSelector( // поиск задачи по идентификатору
    all,
    (state, id) => id,
    (items, id) => items.find((item) => item.id === id)
)(
    (state, id) => id
);

Файл src/redux/userSelectors.js:

export const auth = (state) => state.user.auth;

В предыдущей версии приложения в директории src/redux у нас было тринадцать файлов, а в этой версии получилось девять. Количество файлов уменьшилось на 30%, а мы еще не использовали функцию createSlice, которая заменяет createAction и createReducer.

Создаем компоненты

Содержимое директории src/component будет как и в предыдущей версии приложения, так что просто копируем из react-redux-hooks-five.

Исходные коды

Исходные коды здесь, директория react-redux-toolkit-one.

$ cd react-redux/react-redux-toolkit-one
$ npm install

Приложение, второй вариант

Нам потребуется развернуть React приложение с помощью create-react-app + установить два пакета — react-redux и @reduxjs/toolkit.

$ cd react-redux/react-redux-toolkit
$ npx create-react-app .
$ npm install react-redux @reduxjs/toolkit --save-prod
$ npm install uuid re-reselect --save-prod # доп.пакеты

Создаем хранилище

Для начала нам нужно хранилище, для этого создаем директорию src/redux, внутри нее — все необходимое для работы Redux.

Файл src/redux/store.js:

import { configureStore } from '@reduxjs/toolkit';
import todoReducer from './todoSlice.js';
import userReducer from './userSlice.js';

export const store = configureStore({
    reducer: {
        todo: todoReducer,
        user: userReducer,
    },
});

Файл src/redux/todoSlice.js:

import { createSlice } from '@reduxjs/toolkit';

const initialState = [
    { id: 1, title: 'Первая задача', completed: false },
    { id: 2, title: 'Вторая задача', completed: true },
    { id: 3, title: 'Третья задача', completed: false },
    { id: 4, title: 'Четвертая задача', completed: true },
    { id: 5, title: 'Пятая задача', completed: false },
];

const todoSlice = createSlice({
    name: 'todo',
    initialState: initialState,
    reducers: {
        create(state, action) {
            state.push(action.payload); // мутация state
        },
        toggle(state, action) {
            const item = state.find((item) => item.id === action.payload);
            item.completed = !item.completed; // мутация state
        },
        remove(state, action) {
            return state.filter((item) => item.id !== action.payload); // возврат нового state
        },
    },
});

export const { create, toggle, remove } = todoSlice.actions; // генераторы действий

export default todoSlice.reducer;

Файл src/redux/userSlice.js:

import { createSlice } from '@reduxjs/toolkit';

const initialState = {
    auth: false,
};

const userSlice = createSlice({
    name: 'user',
    initialState: initialState,
    reducers: {
        login(state, action) {
            state.auth = true;
        },
        logout(state, action) {
            state.auth = false;
        },
    },
});

export const { login, logout } = userSlice.actions; // генераторы действий

export default userSlice.reducer;

Файл src/redux/actions.js:

import { create as todoCreate, toggle as todoToggle, remove as todoRemove } from './todoSlice.js';
import { login as userLogin, logout as userLogout } from './userSlice.js';

export const actions = {
    todo: {
        create: todoCreate,
        toggle: todoToggle,
        remove: todoRemove,
    },
    user: {
        login: userLogin,
        logout: userLogout,
    },
};

Файл src/redux/selectors.js:

import {
    all,
    allLength,
    completed,
    completedLength,
    uncompleted,
    uncompletedLength,
    findById,
    ids,
} from './todoSelectors.js';
import { auth } from './userSelectors.js';

export const selectors = {
    todo: {
        all: all,
        allLength: allLength,
        completed: completed,
        completedLength: completedLength,
        uncompleted: uncompleted,
        uncompletedLength: uncompletedLength,
        findById: findById,
        ids: ids,
    },
    user: {
        auth: auth,
    },
};

Файл src/redux/todoSelectors.js:

// функцию createSelector теперь можно импортировать из @reduxjs/toolkit
import { createSelector } from '@reduxjs/toolkit';
import { createCachedSelector } from 're-reselect';

export const all = (state) => state.todo; // массив всех задач
export const allLength = createSelector( // количество всех задач
    all,
    (items) => items.length
);
export const completed = createSelector( // массив завершенных задач
    all,
    (items) => items.filter((item) => item.completed)
);
export const completedLength = createSelector( // количество завершенных задач
    completed,
    (items) => items.length
);
export const uncompleted = createSelector( // массив не завершенных задач
    all,
    (items) => items.filter((item) => !item.completed)
);
export const uncompletedLength = createSelector( // количество не завершенных задач
    uncompleted,
    (items) => items.length
);
export const ids = createSelector( // идентификаторы всех задач
    all,
    (items) => items.map((item) => item.id)
);
export const findById = createCachedSelector( // поиск задачи по идентификатору
    all,
    (state, id) => id,
    (items, id) => items.find((item) => item.id === id)
)(
    (state, id) => id
);

Файл src/redux/userSelectors.js:

export const auth = (state) => state.user.auth;

У нас получилось семь файлов в директории src/redux, что почти на 50% меньше, чем при использовании классического Redux.

Создаем компоненты

Содержимое директории src/component будет как и в предыдущей версии приложения, так что просто копируем из react-redux-toolkit-one.

Исходные коды

Исходные коды здесь, директория react-redux-toolkit-two.

$ cd react-redux/react-redux-toolkit-two
$ npm install

Поиск: Frontend • Hook • JavaScript • React.js • Web-разработка • Состояние • Список • Теория • Функция

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.