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

17.09.2022

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

Асинхронное приложение

Наше приложение списка дел работает синхронно. Каждый раз при отправке экшена — состояние немедленно обновляется. Давайте теперь создадим асинхронное приложение, которое будет использовать API JsonPlaceholder. При вызове асинхронного API, есть два ключевых момента времени — момент отправки запроса и момент получения ответа.

Для каждого из этих моментов нам нужно изменять состояние приложения. Для изменения состояния мы запускаем обычные экшены, которые будут обработаны редюсером синхронно. Таких экшенов у нас будет три:

  • Экшен, информирующий редюсер о том, что запрос был отправлен
  • Экшен, информирующий редюсер о том, что запрос успешно завершился
  • Экшен, информирующий редюсер о том, что запрос завершился неудачей

Middleware thunk

Redux middleware позводяют выполнить код между моментом отправкой экшена и моментом, когда этот экшен достигает редюсера. Для асинхронной работы создают не настоящий генератор экшена (возвращает объект), а псевдо-экшен, который возвращает функцию. Middleware thunk проверяет, является ли экшен объектом или функцией, и если это функция — вызывает ее. Внутри этой функции мы можем отправлять настоящие экшены — в момент отправки запроса и получения ответа.

// простой вариант реализации middleware thunk
const thunk = store => next => action => {
    if (typeof action === 'function') {
        return action(store.dispatch, store.getState);
    }
    return next(action);
};

Будем дорабатывать приложение, которое создали во предыдущей части. При запуске приложения — нам нужно запросить список задач с удаленного сервера.

https://jsonplaceholder.typicode.com/todos
[
    {
        "userId": 1,
        "id": 1,
        "title": "delectus aut autem",
        "completed": false
    },
    {
        "userId": 1,
        "id": 2,
        "title": "quis ut nam facilis et officia qui",
        "completed": false
    },
    {
        "userId": 1,
        "id": 3,
        "title": "fugiat veniam minus",
        "completed": false
    },
    {
        "userId": 1,
        "id": 4,
        "title": "et porro tempora",
        "completed": true
    },
    ..........
]

Middleware thunk уже доступно по умолчанию после установки Redux Toolkit, так что ничего больше устанавливать не нужно.

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

const initialState = {
    items: [],
    loading: false,
    error: null,
};

const todoSlice = createSlice({
    name: 'todo',
    initialState: initialState,
    reducers: {
        create(state, action) {
            state.items.push(action.payload);
        },
        toggle(state, action) {
            const item = state.items.find((item) => item.id === action.payload);
            item.completed = !item.completed;
        },
        remove(state, action) {
            const items = state.items.filter((item) => item.id !== action.payload);
            state.items = items;
        },
        fetchStarted(state, action) { // отправка запроса
            state.loading = true;
            state.error = null;
        },
        fetchSuccess(state, action) { // получение ответа
            state.loading = false;
            state.error = null;
            state.items = action.payload;
        },
        fetchFailure(state, action) { // произошла ошибка
            state.loading = false;
            state.error = action.payload;
        },
    },
});

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

export const fetchProcess = () => { // псевдо-экшен, который возвращает не объект, а функцию
    return async (dispatch, getState) => {
        dispatch(fetchStarted()); // отправка запроса
        try {
            const response = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=8');
            if (!response.ok) {
                throw new Error('Ошибка при получении списка задач');
            }
            const jsonData = await response.json();
            const items = jsonData.map((item) => {
                return { id: item.id, title: item.title, completed: item.completed };
            });
            dispatch(fetchSuccess(items)); // получение ответа
        } catch (error) {
            dispatch(fetchFailure(error.message)); // произошла ошибка
        }
    };
};

export default todoSlice.reducer;

Состояние списка задач теперь не просто массив, а объект с полями items, loading и error. Так что надо внести маленькое изменение в файл src/redux/todoSelectors.js.

export const all = (state) => state.todo.items; // массив всех задач

И осталось только отправить наш псевдо-экшен с помощью функции store.dispatch в компоненте TodoList.js.

import { useEffect } from 'react';
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
import { fetchProcess } from '../redux/todoSlice.js';
import { selectors } from '../redux/selectors.js';
import { TodoItem } from './TodoItem.js';

export function TodoList(props) {
    const ids = useSelector(selectors.todo.ids, shallowEqual);
    const loading = useSelector((state) => state.todo.loading);
    const error = useSelector((state) => state.todo.error);

    const dispatch = useDispatch();

    useEffect(() => { // получить список задач с сервера
        dispatch(fetchProcess());
    }, []);

    if (loading) return <p>Получение списка задач с сервера...</p>;
    if (error) return <p className="error">{error}</p>;

    return (
        <div className="todo-list">
            {ids.length > 0 ? (
                ids.map((id) => <TodoItem key={id} id={id} />)
            ) : (
                <p>Список задач пустой</p>
            )}
        </div>
    );
}

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

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

Создание и удаление задач

Пока мы только запрашиваем с сервера список задач. Но нам нужно еще добавлять, изменять статус и удалять задачи. Для этого отправлять на сервер POST, PATCH и DELETE запросы. И все это асинхронные операции. Так что продолжаем дорабатывать приложение.

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

const initialState = {
    items: [],
    loading: false,
    loadError: null,
    status: null,
    error: null,
};

const todoSlice = createSlice({
    name: 'todo',
    initialState: initialState,
    reducers: {
        createClient(state, action) {
            state.items.push(action.payload); // мутация state
        },
        toggleClient(state, action) {
            const item = state.items.find((item) => item.id === action.payload);
            item.completed = !item.completed; // мутация state
        },
        removeClient(state, action) {
            const items = state.items.filter((item) => item.id !== action.payload);
            state.items = items; // мутация state
        },

        loadStarted(state, action) {
            state.loading = true;
            state.loadError = null;
        },
        loadSuccess(state, action) {
            state.loading = false;
            state.loadError = null;
            state.items = action.payload;
        },
        loadFailure(state, action) {
            state.loading = false;
            state.loadError = action.payload;
        },

        exchangeStarted(state, action) {
            state.error = null;
            state.status = 'Обмен данными с сервером, ждите...';
        },
        exchangeSuccess(state, action) {
            state.error = null;
            state.status = null;
        },
        exchangeFailure(state, action) {
            state.error = action.payload;
            state.status = null;
        },
    },
});

const {
    createClient,
    toggleClient,
    removeClient,
    loadStarted,
    loadSuccess,
    loadFailure,
    exchangeStarted,
    exchangeSuccess,
    exchangeFailure
} = todoSlice.actions; // генераторы действий

export const loadProcess = () => {
    return async (dispatch, getState) => {
        dispatch(loadStarted());
        setTimeout(async () => { // для увеличения задержки, чтобы увидеть loader
            try {
                const response = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=8');
                if (!response.ok) {
                    throw new Error('Ошибка при получении списка задач');
                }
                const items = await response.json();
                dispatch(loadSuccess(items));
            } catch (error) {
                dispatch(loadFailure(error.message));
            }
        }, 1000);
    };
};

const createProcess = (data) => {
    return async (dispatch, getState) => {
        dispatch(exchangeStarted());
        try {
            const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify(data),
            });
            if (!response.ok) {
                throw new Error('Ошибка при добавлении новой задачи');
            }
            const newTodo = await response.json();
            dispatch(createClient(newTodo));
            dispatch(exchangeSuccess());
        } catch (error) {
            dispatch(exchangeFailure(error.message));
        }
    };
};

const toggleProcess = (id) => {
    return async (dispatch, getState) => {
        dispatch(exchangeStarted());
        const todo = getState().todo.items.find((todo) => todo.id === id);
        try {
            const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
                method: 'PATCH',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({
                    completed: !todo.completed,
                }),
            });
            if (!response.ok) {
                throw new Error('Ошибка при изменении статуса задачи');
            }
            dispatch(toggleClient(id));
            dispatch(exchangeSuccess());
        } catch (error) {
            dispatch(exchangeFailure(error.message));
        }
    };
};

const removeProcess = (id) => {
    return async (dispatch, getState) => {
        dispatch(exchangeStarted());
        try {
            const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
                method: 'DELETE',
            });
            if (!response.ok) {
                throw new Error('Ошибка при удалении задачи');
            }
            dispatch(removeClient(id));
            dispatch(exchangeSuccess());
        } catch (error) {
            dispatch(exchangeFailure(error.message));
        }
    };
};

export { createProcess as create, toggleProcess as toggle, removeProcess as remove };

export default todoSlice.reducer;
import { useEffect } from 'react';
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
import { loadProcess } from '../redux/todoSlice.js';
import { selectors } from '../redux/selectors.js';
import { TodoItem } from './TodoItem.js';

export function TodoList(props) {
    const ids = useSelector(selectors.todo.ids, shallowEqual);
    const loading = useSelector((state) => state.todo.loading);
    const loadError = useSelector((state) => state.todo.loadError);
    const status = useSelector((state) => state.todo.status);
    const error = useSelector((state) => state.todo.error);

    const dispatch = useDispatch();

    useEffect(() => {
        dispatch(loadProcess());
    }, []);

    if (loading) return <p>Получение списка задач с сервера...</p>;
    if (loadError) return <p className="error">{loadError}</p>;

    return (
        <>
            {status && <p className="status">{status}</p>}
            {error && <p className="error">{error}</p>}
            <div className="todo-list">
                {ids.length > 0 ? (
                    ids.map((id) => <TodoItem key={id} id={id} />)
                ) : (
                    <p>Список задач пустой</p>
                )}
            </div>
        </>
    );
}
import { useState } from 'react';
import { useDispatch } from 'react-redux';
import { actions } from '../redux/actions.js';

export function TodoForm(props) {
    const [text, setText] = useState('');
    const dispatch = useDispatch();

    const handleChange = (event) => {
        setText(event.target.value);
    };

    const handleClick = () => {
        if (text.trim().length !== 0) {
            const data = {
                title: text.trim(),
                completed: false,
                // jsonplaceholder требует id пользователя
                userId: 1,
            };
            dispatch(actions.todo.create(data));
        }
        setText('');
    };

    return (
        <div className="todo-form">
            <input type="text" value={text} onChange={handleChange} placeholder="Новая задача" />
            <button onClick={handleClick}>Добавить</button>
        </div>
    );
}

Состояние списка задач опять усложнилось. Сначала мы должны загрузить список задач с сервера, а в дальнейшем иметь возможность создавать и удалять задачи. Причем, мы сперва должны создать или удалить задачу на сервере, а потом уже на клиенте — если операция на сервере была успешной.

Мы экспортируем псевдо-генераторы действий createProcess, toggleProcess и removeProcess как create, toggle и remove. Тем самым облегчаем себе жизнь — не нужно во всех компонентах изменять старые имена на новые. Да и незачем компонентам знать подробности реализации — досточно того, что они знают назначение каждого из этих генероторов действий.

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

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

Функция createAsyncThunk

У нас получилось четыре асинхронные задачи — получение списка задач, создание, изменение статуса и удаление задачи. Но в реальном приложении количество таких задач может быть гораздо больше. Поэтому Redux Toolkit предлагает функцию createAsyncThunk, которая стандартизирует решение асинхронных задач в приложении.

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

const loadProcess = createAsyncThunk(
    'todo/loadProcess',
    async function (_, { rejectWithValue }) {
        try {
            const response = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=6');
            if (!response.ok) {
                throw new Error('Ошибка при получении списка задач');
            }
            const items = await response.json();
            return items;
        } catch (error) {
            return rejectWithValue(error.message);
        }
    }
);

const createProcess = createAsyncThunk(
    'todo/createProcess',
    async function (data, { rejectWithValue, dispatch }) {
        try {
            const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify(data),
            });
            if (!response.ok) {
                throw new Error('Ошибка при добавлении новой задачи');
            }
            const newTodo = await response.json();
            dispatch(createClient(newTodo));
        } catch (error) {
            return rejectWithValue(error.message);
        }
    }
);

const toggleProcess = createAsyncThunk(
    'todo/toggleProcess',
    async function (id, { rejectWithValue, dispatch, getState }) {
        const todo = getState().todo.items.find((todo) => todo.id === id);
        try {
            const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
                method: 'PATCH',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({
                    completed: !todo.completed,
                }),
            });
            if (!response.ok) {
                throw new Error('Ошибка при изменении статуса задачи');
            }
            dispatch(toggleClient(id));
        } catch (error) {
            return rejectWithValue(error.message);
        }
    }
);

const removeProcess = createAsyncThunk(
    'todo/removeProcess',
    async function (id, { rejectWithValue, dispatch }) {
        try {
            const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
                method: 'DELETE',
            });
            if (!response.ok) {
                throw new Error('Ошибка при удалении задачи');
            }
            dispatch(removeClient(id));
        } catch (error) {
            return rejectWithValue(error.message);
        }
    }
);

const initialState = {
    items: [],
    loading: false,
    loadError: null,
    status: null,
    error: null,
};

const todoSlice = createSlice({
    name: 'todo',
    initialState: initialState,
    reducers: {
        createClient(state, action) {
            state.items.push(action.payload);
        },
        toggleClient(state, action) {
            const item = state.items.find((item) => item.id === action.payload);
            item.completed = !item.completed;
        },
        removeClient(state, action) {
            const items = state.items.filter((item) => item.id !== action.payload);
            state.items = items;
        },
    },
    extraReducers: (builder) => {
        builder
            // загрузка списка задач с сервера
            .addCase(loadProcess.pending, (state, action) => {
                state.loading = true;
                state.loadError = null;
            })
            .addCase(loadProcess.fulfilled, (state, action) => {
                state.loading = false;
                state.loadError = null;
                state.items = action.payload;
            })
            .addCase(loadProcess.rejected, (state, action) => {
                state.loading = false;
                state.loadError = action.payload;
            })
            // создание новой задачи
            .addCase(createProcess.pending, (state, action) => {
                state.error = null;
                state.status = 'Создание новой задачи, ждите...';
            })
            .addCase(createProcess.fulfilled, (state, action) => {
                state.error = null;
                state.status = null;
            })
            .addCase(createProcess.rejected, (state, action) => {
                state.error = action.payload;
                state.status = null;
            })
            // изменение статуса задачи
            .addCase(toggleProcess.pending, (state, action) => {
                state.error = null;
                state.status = 'Изменение статуса задачи, ждите...';
            })
            .addCase(toggleProcess.fulfilled, (state, action) => {
                state.error = null;
                state.status = null;
            })
            .addCase(toggleProcess.rejected, (state, action) => {
                state.error = action.payload;
                state.status = null;
            })
            // удаление задачи
            .addCase(removeProcess.pending, (state, action) => {
                state.error = null;
                state.status = 'Удаление задачи, ждите...';
            })
            .addCase(removeProcess.fulfilled, (state, action) => {
                state.error = null;
                state.status = null;
            })
            .addCase(removeProcess.rejected, (state, action) => {
                state.error = action.payload;
                state.status = null;
            });
    },
});

const { createClient, toggleClient, removeClient } = todoSlice.actions; // генераторы действий

export {
    loadProcess,
    createProcess as create,
    toggleProcess as toggle,
    removeProcess as remove,
};

export default todoSlice.reducer;

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

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

Нормализация

Термин «нормализация» пришел из мира баз данных. Это относится к преобразованию схемы базы данных для удаления избыточной информации. Избыточная информация означает одни и те же данные, хранящиеся более чем в одном месте. Денормализованное состояние трудно обновлять, потому что обновлять нужно сразу в нескольких местах.

Часто структура хранилища в приложении отражает формат данных, которые были получены от API. Однако так быть не должно. Обычно формат данных, которые получены от сервера, отражает то, как сервер хранит эти данные. Скорее всего, этот формат будет отличаться от того, как эти данные должны быть структурированы для клиента.

Разработчики Redux рекомендют такую схему хранения данных:

{
    posts: {
        entities: {
            'post1': {
                id: 'post1',
                author: 'user1',
                content: '......',
                comments: ['comment1', 'comment2']
            },
            'post2': {
                id: 'post2',
                author : 'user2',
                content: '......',
                comments: ['comment3', 'comment4', 'comment5']
            }
        },
        ids: ['post1', 'post2']
    },
    comments: {
        entities: {
            'comment1': {
                id: 'comment1',
                author: 'user2',
                comment: '.....',
            },
            'comment2': {
                id: 'comment2',
                author: 'user3',
                comment: '.....',
            },
            'comment3': {
                id: 'comment3',
                author: 'user3',
                comment: '.....',
            },
            'comment4': {
                id: 'comment4',
                author: 'user1',
                comment: '.....',
            },
            'comment5': {
                id: 'comment5',
                author: 'user3',
                comment: '.....',
            },
        },
        ids: ['comment1', 'comment2', 'comment3', 'comment4', 'comment5']
    },
    users: {
        entities: {
            'user1': {
                username: 'user1',
                name: 'User 1',
            },
            'user2': {
                username: 'user2',
                name: 'User 2',
            },
            'user3': {
                username: 'user3',
                name: 'User 3',
            }
        },
        ids: ['user1', 'user2', 'user3']
    }
}

Библиотека normalizr

Мы можем нормализовать данные с помощью библиотеки normalizr:

import { normalize, schema } from 'normalizr';

const blogData = {
    posts: [
        {
            id: 'post1',
            author: { id: 'user1', name: 'User name 1' },
            content: 'Контент первого поста блога',
            comments: [
                {
                    uuid: 'comment1',
                    author: { id: 'user2', name: 'User name 2' },
                    content: 'Первый комментарий',
                },
                {
                    uuid: 'comment2',
                    author: { id: 'user3', name: 'User name 3' },
                    content: 'Второй комментарий',
                },
            ],
        },
        {
            id: 'post2',
            author: { id: 'user2', name: 'User name 2' },
            content: 'Контент второго поста блога',
            comments: [
                {
                    uuid: 'comment3',
                    author: { id: 'user3', name: 'User name 3' },
                    content: 'Третий комментарий',
                },
                {
                    uuid: 'comment4',
                    author: { id: 'user1', name: 'User name 1' },
                    content: 'Четвертый комментарий',
                },
                {
                    uuid: 'comment5',
                    author: { id: 'user3', name: 'User name 3' },
                    content: 'Пятый комментарий',
                },
            ],
        },
    ],
};

/*
 * Сущность «Пользователь» — это объект с обязательным полем id
 */
const userSchema = new schema.Entity('users', {});

/*
 * Сущность «Комментарий» — это объект с обязательным полем uuid
 */
const commentSchema = new schema.Entity(
    'comments',
    // поле author в комментарии расценивать как сущность «Пользователь»
    { author: userSchema },
    // по умолчанию normalizr ищет поле id, но в нашем случае это uuid
    { idAttribute: 'uuid' }
);
const commentListSchema = [commentSchema]; // схема массива комментариев

/*
 * Сущность «Пост блога» — это объект с обязательным полем id
 */
const postSchema = new schema.Entity('posts', {
    // поле author в посте блога расценивать как сущность «Пользователь»
    author: userSchema,
    // поле comments в посте расценивать как массив сущностей «Комментарий»
    comments: commentListSchema,
});
const postListSchema = [postSchema]; // схема массива постов блога

/*
 * Наши входные данные представлены объектом { posts: [{пост1}, {пост2}] }
 */
const blogDataSchema = { posts: postListSchema };

const normalizedData = normalize(blogData, blogDataSchema);
console.log(normalizedData);
{
    entities: {
        users: {
            'user1': {
                id: 'user1',
                name: 'User name 1'
            },
            user2: {
                id: 'user2',
                name: 'User name 2'
            },
            'user3': {
                id: 'user3',
                name: 'User name 3'
            }
        },
        comments: {
            'comment1': {
                uuid: 'comment1',
                author: 'user2',
                content: 'Первый комментарий'
            },
            'comment2': {
                uuid: 'comment2',
                author: 'user3',
                content: 'Второй комментарий'
            },
            'comment3': {
                uuid: 'comment3',
                author: 'user3',
                content: 'Третий комментарий'
            },
            'comment4': {
                uuid: 'comment4',
                author: 'user1',
                content: 'Четвертый комментарий'
            },
            'comment5': {
                uuid: 'comment5',
                author: 'user3',
                content: 'Пятый комментарий'
            }
        },
        posts: {
            'post1': {
                id: 'post1',
                author: 'user1',
                content: 'Контент первого поста блога',
                comments: [
                    'comment1',
                    'comment2'
                ]
            },
            'post2': {
                id: 'post2',
                author: 'user2',
                content: 'Контент второго поста блога',
                comments: [
                    'comment3',
                    'comment4',
                    'comment5'
                ]
            }
        }
    },
    result: {
        posts: [
            'post1',
            'post2'
        ]
    }
}

Entity Adapter

Приложение обычно работает с сущностями — посты блога, комментарии, пользователи. Эти сущности надо получать — получить список постов блога, получить отдельный пост блога по идентификатору. И над этими сущностями надо выполнять CRUD-операции — то есть создавать, обновлять, удалять. Было бы неправильно создавать множество практически одинаковых функций — addPost, addComment, addUser, selectPostById, selectCommentById, selectUserById и т.п.

Поэтому Redux Toolkit предоставляет в наше распоряжение функцию createEntityAdapter. В качестве аргумента функция может принимать объект с полями selectId и sortComparer. Значение selectId — это функция, которая позволяет найти идентификатор сущности (по умолчанию id). Значение sortComparer — это функция сравнения, которая позволяет отсортировать массив ids.

const userData = {
    users: [
        { uuid: '12345', name: 'Сергей' },
        { uuid: '67890', name: 'Андрей' },
    ],
};
export const usersAdapter = createEntityAdapter({
    selectId: (user) => user.uuid, // поле идентифиактора сущности — это uuid
    sortComparer: (a, b) => a.name.localeCompare(b.name), // сортировка по имени
});
import { createSlice, createEntityAdapter } from '@reduxjs/toolkit';

const userAdapter = createEntityAdapter();

// по умолчанию { entities: {}, ids: [] }
const initialState = userAdapter.getInitialState();

const userSlice = createSlice({
    name: 'user',
    initialState,
    reducers: {
        addUser: userAdapter.addOne,
        addUsers: userAdapter.addMany,
        // если нужна дополнительная обработка, то создаем свою функцию
        removeUser: (state, { payload }) => {
            /* .......... */
            userAdapter.removeOne(state, payload);
        },
        updateUser: userAdapter.updateOne,
    },
});
// в передаваемых данных должен быть id
dispatch(addUser(user));
// данные передаются в формате { id, changes }
dispatch(updateUser({ id: user.id, changes: data }));
// достаточно передать идентификатор
dispatch(removeUser(user.id));

Всего несколько строк кода — и мы получили полноценную реализацию стандартных операций над пользователем. Кроме того, Entity Adapter дает нам набор готовых селекторов для извлечения данных из хранилища.

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

// есть два варианта работы с селекторами, важно не перепутать
const simpleSelectors = userAdapter.getSelectors();
const globalSelectors = userAdapter.getSelectors((state) => state.user);

// этому селектору нужно указать объект, который хранит сущности
const userIds = simpleSelectors.selectIds(store.getState().user);

// этот селектор знает, как найти объект, который хранит сущности
const allUsers = globalSelectors.selectAll(store.getState());

Функции-селекторы, которые нам доступны при использовании Entity Adapter:

  • selectAll — массив сущностей, отсортированный по ids
  • selectIds — возвращает массив ids
  • selectEntities – возвращает объект entities
  • selectTotal — возвращает общее количество сущностей
  • selectById — возвращает сущность или undefined

Все функции-селекторы создаются с использованием createSelector из пакета reselect, так что запоминают вычисленый результат.

CRUD-операции с Entity Adapter

Функции setAll(), addMany() и upsertMany() ожидают получения массива сущностей entitiesArray или объекта типа entitiesObject, что облегчает добавление предварительно нормализованных данных.

const entitiesArray = [
    {
        id: '05f05bf7-6a67-4704-90df-2b781dc0117a',
        name: 'Сергей Иванов'
    },
    {
        id: '10226ccf-2c6a-48e2-8bf1-c94a4a483289',
        name: 'Николай Петров'
    },
];
const entitiesObject = {
    '05f05bf7-6a67-4704-90df-2b781dc0117a': {
        id: '05f05bf7-6a67-4704-90df-2b781dc0117a',
        name: 'Сергей Иванов'
    },
    '10226ccf-2c6a-48e2-8bf1-c94a4a483289': {
        id: '10226ccf-2c6a-48e2-8bf1-c94a4a483289',
        name: 'Николай Петров'
    },
};
const entitiesArray = Oblect.values(entitiesObject);

Функция updateOne принимает объект обновления {id:'…', changes:{…}}, где changes содержит одно или несколько новых значений. Функция updateMany принимает массив объектов обновления [{id, changes}, {id, changes}, …].

const data = { id: '...', name: '...', age: '...' };
const { id, ...changes } = data;
entityAdapter.updateOne(state, { id, changes });

Функция upsertOne принимает объект сущности {id:'…', name:'…', …}. А вот функция upsertMany — всеядная, может принимать как массив объектов сущностей entitiesArray, так и объект типа entitiesObject, полученный от функции normalize.

const data = { id: '...', name: '...', age: '...' };
entityAdapter.upsertOne(state, data);
const post = {
    'b06a4f95-ba8e-470a-b16a-07c4cfcd05b3': {
        id: 'b06a4f95-ba8e-470a-b16a-07c4cfcd05b3',
        title: 'Первый пост блога',
        author: 'b36dd304-46fb-4b47-8f3c-4120c60b9553',
    }
}
entityAdapter.upsertOne(state, Object.values(post)[0]);
const posts = {
    'b06a4f95-ba8e-470a-b16a-07c4cfcd05b3': {
        id: 'b06a4f95-ba8e-470a-b16a-07c4cfcd05b3',
        title: 'Первый пост блога',
        author: 'b36dd304-46fb-4b47-8f3c-4120c60b9553',
    },
    'ac84ee7f-efb2-4084-9f99-3bf11efa18e1': {
        id: 'ac84ee7f-efb2-4084-9f99-3bf11efa18e1',
        title: 'Второй пост блога',
        author: '8e776624-99af-4ca7-a183-fe9f29ef7b8a',
    },
}
entityAdapter.upsertMany(state, posts);
entityAdapter.upsertMany(state, Object.values(posts));

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

[
    {
        id: 'post1',
        author: { id: 'user1', name: 'User name 1' },
        content: 'Контент первого поста блога',
        comments: [
            {
                uuid: 'comment1',
                author: { id: 'user2', name: 'User name 2' },
                content: 'Первый комментарий',
            },
            {
                uuid: 'comment2',
                author: { id: 'user3', name: 'User name 3' },
                content: 'Второй комментарий',
            },
        ],
    },
    {
        id: 'post2',
        author: { id: 'user2', name: 'User name 2' },
        content: 'Контент второго поста блога',
        comments: [
            {
                uuid: 'comment3',
                author: { id: 'user3', name: 'User name 3' },
                content: 'Третий комментарий',
            },
            {
                uuid: 'comment4',
                author: { id: 'user1', name: 'User name 1' },
                content: 'Четвертый комментарий',
            },
            {
                uuid: 'comment5',
                author: { id: 'user3', name: 'User name 3' },
                content: 'Пятый комментарий',
            },
        ],
    },
]
// файл postSlice.js
const entityAdapter = createEntityAdapter();

export const loadProcess = createAsyncThunk(
    'post/loadProcess',
    async (id) => {
        const data = await fakeAPI.posts.getAll(id);
        // Нормализуем данные, чтобы редуктор получил payload типа
        // {posts: {1:{id:1, …}, 2:{id:2, …}, …}, users: {…}, comments: {…}}
        const normalized = normalize(data, schema);
        return normalized.entities;
    }
);

export const entitySlice = createSlice({
    name: 'post',
    initialState: entityAdapter.getInitialState(),
    reducers: {},
    extraReducers: {
        [loadProcess.fulfilled]: (state, action) => {
            // Обрабатываем результат запроса, добавляя посты блога
            entityAdapter.upsertMany(state, action.payload.posts);
        },
    },
});

export default entitySlice.reducer;
// файл userSlice.js
const entityAdapter = createEntityAdapter();

const entitySlice = createSlice({
    name: 'user',
    initialState: entityAdapter.getInitialState(),
    reducers: {},
    extraReducers: (builder) => {
        builder.addCase(loadProcess.fulfilled, (state, action) => {
            // Обрабатываем тот же результат запроса, добавляя пользователей
            entityAdapter.upsertMany(state, action.payload.users);
        });
    },
});

export default entitySlice.reducer;
// файл commentSlice.js
const entityAdapter = createEntityAdapter();

const entitySlice = createSlice({
    name: 'comment',
    initialState: entityAdapter.getInitialState(),
    reducers: {},
    extraReducers: (builder) => {
        builder.addCase(loadProcess.fulfilled, (state, action) => {
            // Обрабатываем тот же результат запроса, добавляя комментарии
            entityAdapter.upsertMany(state, action.payload.comments);
        });
    },
});

export default entitySlice.reducer;

Селекторы с Entity Adapter

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

// Переименовываем экспорты для удобства их использовании в компонентах
export const {
    selectById: selectUserById,
    selectIds: selectUserIds,
    selectEntities: selectUserEntities,
    selectAll: selectAllUsers,
    selectTotal: selectTotalUsers,
} = entityAdapter.getSelectors((state) => state.user);
import React from 'react';
import { useSelector } from 'react-redux';
import { selectTotalUsers, selectAllUsers } from './userSlice.js';

export function UserList() {
    const count = useSelector(selectTotalUsers);
    const users = useSelector(selectAllUsers);

    return (
        <div>
            <div className={styles.row}>
                Количество пользователей: {count}
            </div>
            {users.map((user) => (
                <div key={user.id}>
                    {user.name} {user.surname}
                </div>
            ))}
        </div>
    );
}

Поиск: API • 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.