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

02.10.2022

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

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

Продолжаем разговор про нормализацию и функцию createEntityAdapter. Чтобы закрепить полученные знания, напишем небольшое приложение. Это будет список постов блога с метками (тегами). Список постов получим с сервера, нормализуем данные для удобства работы, добавим возможность создавать, редактировать и удалять посты.

Простое приложение

Нам потребуется сервер, поэтому в директории проекта создаем директории server и client.

Сервер

Нужно установить пакеты express, cors и uuid + dev-зависимость nodemon, чтобы иметь возможность «на лету» редактировать код.

$ cd react-redux/react-redux-toolkit-adapter/server
$ npm install express --save-prod
$ npm install cors --save-prod
$ npm install uuid --save-prod
$ npm install nodemon --save-dev
{
    "name": "react-redux-toolkit-adapter-server",
    "version": "0.1.0",
    "private": true,
    "main": "index.mjs",
    "type": "module",
    "scripts": {
        "start-prod": "node index.js",
        "start-dev": "nodemon index.js"
    },
    "keywords": [],
    "author": "",
    "license": "ISC",
    "dependencies": {
        "cors": "^2.8.5",
        "express": "^4.18.1",
        "uuid": "^9.0.0"
    },
    "devDependencies": {
        "nodemon": "^2.0.20"
    }
}
import express from 'express';
import cors from 'cors';
import { v4 as uuid } from 'uuid';

const postIdOne = uuid();
const postIdTwo = uuid();
const postIdThree = uuid();

const userIdOne = uuid();
const userIdTwo = uuid();
const userIdThree = uuid();

const tagIdOne = uuid();
const tagIdTwo = uuid();
const tagIdThree = uuid();

const posts = [
    {
        id: postIdOne,
        title: 'Первый пост блога',
        author: { id: userIdOne, name: 'Сергей Иванов' },
        tags: [
            { id: tagIdOne, name: 'Первая метка' },
            { id: tagIdTwo, name: 'Вторая метка' },
        ],
    },
    {
        id: postIdTwo,
        title: 'Второй пост блога',
        author: { id: userIdTwo, name: 'Николай Петров' },
        tags: [
            { id: tagIdOne, name: 'Первая метка' },
            { id: tagIdThree, name: 'Третья метка' },
        ],
    },
    {
        id: postIdThree,
        title: 'Третий пост блога',
        author: { id: userIdThree, name: 'Андрей Смирнов' },
        tags: [
            { id: tagIdTwo, name: 'Вторая метка' },
            { id: tagIdThree, name: 'Третья метка' },
        ],
    },
];

const delay = (ms) => {
    let current = Date.now();
    const future = current + ms;
    while (current < future) {
        current = Date.now();
    }
};

const app = express();
app.use(cors());
app.use(express.json());

// GET-запрос на получение списка постов
app.get('/api/blog', (req, res) => {
    delay(1000);
    res.json(posts);
});

// GET-запрос поста по идентификатору
app.get('/api/blog/:id', (req, res) => {
    delay(1000);
    const post = posts.find((item) => item.id === req.params.id);
    if (post) {
        res.json(post);
    } else {
        res.status(404).send();
    }
});

// POST-запрос на добавление поста блога
app.post('/api/blog', (req, res) => {
    delay(1000);
    const newPost = {
        id: uuid(),
        title: req.body.title,
        author: req.body.author,
        tags: req.body.tags,
    };
    posts.push(newPost);
    // возвращаем в ответе новый пост
    res.json(newPost);
});

// PUT и PATCH запросы на обновление поста
const update = (req, res) => {
    delay(1000);
    const id = req.params.id;
    const post = posts.find((item) => item.id === id);
    if (post) {
        if (req.body.title !== undefined) post.title = req.body.title;
        if (req.body.author !== undefined) post.author = req.body.author;
        if (req.body.tags !== undefined) post.tags = req.body.tags;
        // возвращаем в ответе обновленный пост
        res.json(post);
    } else {
        res.status(404).send();
    }
};
app.put('/api/blog/:id', update);
app.patch('/api/blog/:id', update);

// DELETE-запрос на удаление поста блога
app.delete('/api/blog/:id', (req, res) => {
    delay(1000);
    const index = posts.findIndex((item) => item.id === req.params.id);
    if (index >= 0) {
        const deleted = posts.splice(index, 1);
        // возвращаем в ответе удаленный пост
        res.json(deleted[0]);
    } else {
        res.status(404).send();
    }
});

app.listen(5000);

Из директории server запускаем сервер:

$ npm run start-dev

На GET-запрос списка постов блога сервер выдает JSON следующего формата:

[
    {
        "id": "0a9a339b-f401-4966-a1db-67349f30aef0",
        "title": "Первый пост блога",
        "author": {
            "id": "944d30b0-a067-4b01-8d25-25a725a7ff8e",
            "name": "Сергей Иванов"
        },
        "tags": [
            {
                "id": "3bc4318a-874e-45cb-863e-aaa1e2cd6c7b",
                "name": "Первая метка"
            },
            {
                "id": "9707c42b-1b14-4834-8b4f-bb9505a67b8b",
                "name": "Вторая метка"
            }
        ]
    },
    {
        "id": "f824a4c7-547f-49e1-9e6e-8401aaff282a",
        "title": "Второй пост блога",
        "author": {
            "id": "2a0929d8-7e4e-4036-b0cb-2ee4b384cf5a",
            "name": "Николай Петров"
        },
        "tags": [
            {
                "id": "3bc4318a-874e-45cb-863e-aaa1e2cd6c7b",
                "name": "Первая метка"
            },
            {
                "id": "a85fb004-023e-454d-bb50-5b5989fc5f8e",
                "name": "Третья метка"
            }
        ]
    },
    {
        "id": "d0cf596f-0b1a-4649-ae48-7aae4b6debe2",
        "title": "Третий пост блога",
        "author": {
            "id": "b0c78d6a-7329-45fe-9e65-b2f3cffb4fe0",
            "name": "Андрей Смирнов"
        },
        "tags": [
            {
                "id": "9707c42b-1b14-4834-8b4f-bb9505a67b8b",
                "name": "Вторая метка"
            },
            {
                "id": "a85fb004-023e-454d-bb50-5b5989fc5f8e",
                "name": "Третья метка"
            }
        ]
    }
]

На POST, PATCH, DELETE запрос сервер выдает созданный, обновленный или удаленный пост:

{
    "id": "f824a4c7-547f-49e1-9e6e-8401aaff282a",
    "title": "Второй пост блога",
    "author": {
        "id": "2a0929d8-7e4e-4036-b0cb-2ee4b384cf5a",
        "name": "Николай Петров"
    },
    "tags": [
        {
            "id": "3bc4318a-874e-45cb-863e-aaa1e2cd6c7b",
            "name": "Первая метка"
        },
        {
            "id": "a85fb004-023e-454d-bb50-5b5989fc5f8e",
            "name": "Третья метка"
        }
    ]
}

Клиент

На клиенте нужно развернуть React-приложение с помощью create-react-app и установить пакеты @reduxjs/toolkit, react-redux и normalizr.

$ cd react-redux/react-redux-toolkit-adapter/client # можно вот так
$ npx create-react-app .
$ npm install @reduxjs/toolkit --save-prod
$ npm install react-redux --save-prod
$ npm install normalizr --save-prod
$ cd react-redux/react-redux-toolkit-adapter/client # или вот так
$ npx create-react-app . --template redux
$ npm install normalizr --save-prod
{
    "name": "react-redux-toolkit-adapter-client",
    "version": "0.1.0",
    "private": true,
    "dependencies": {
        "@reduxjs/toolkit": "^1.8.5",
        "@testing-library/jest-dom": "^5.16.5",
        "@testing-library/react": "^13.3.0",
        "@testing-library/user-event": "^13.5.0",
        "normalizr": "^3.6.2",
        "react": "^18.2.0",
        "react-dom": "^18.2.0",
        "react-redux": "^8.0.2",
        "react-scripts": "5.0.1",
        "web-vitals": "^2.1.4"
    },
    "scripts": {
        "start": "react-scripts start",
        "build": "react-scripts build",
        "test": "react-scripts test",
        "eject": "react-scripts eject"
    },
    "eslintConfig": {
        "extends": [
            "react-app",
            "react-app/jest"
        ]
    },
    "browserslist": {
        "production": [
            ">0.2%",
            "not dead",
            "not op_mini all"
        ],
        "development": [
            "last 1 chrome version",
            "last 1 firefox version",
            "last 1 safari version"
        ]
    }
}
Создаем хранилище

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

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

import { configureStore } from '@reduxjs/toolkit';
import postReducer from './postSlice.js';
import userReducer from './userSlice.js';
import tagReducer from './tagSlice.js';

export const store = configureStore({
    reducer: {
        post: postReducer,
        user: userReducer,
        tag: tagReducer,
    },
});

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

import { createSlice, createAsyncThunk, createEntityAdapter } from '@reduxjs/toolkit';
import { postSchema, postListSchema } from './schema.js';
import { normalize } from 'normalizr';

const API_URL = 'http://localhost:5000/api/blog';

const loadProcess = createAsyncThunk('post/loadProcess', async function (_, { rejectWithValue }) {
    try {
        const response = await fetch(API_URL);
        if (!response.ok) {
            throw new Error('Ошибка при получении списка постов');
        }
        const serverData = await response.json();
        const normalized = normalize(serverData, postListSchema);
        return normalized.entities;
    } catch (error) {
        return rejectWithValue(error.message);
    }
});

const createProcess = createAsyncThunk(
    'post/createProcess',
    async function (data, { rejectWithValue }) {
        try {
            const response = await fetch(API_URL, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify(data),
            });
            if (!response.ok) {
                throw new Error('Ошибка при добавлении нового поста');
            }
            const serverData = await response.json();
            const normalized = normalize(serverData, postSchema);
            return normalized.entities;
        } catch (error) {
            return rejectWithValue(error.message);
        }
    }
);

const updateProcess = createAsyncThunk(
    'post/updateProcess',
    async function (data, { rejectWithValue }) {
        try {
            const response = await fetch(`${API_URL}/${data.id}`, {
                method: 'PATCH',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify(data),
            });
            if (!response.ok) {
                throw new Error('Ошибка при обновлении поста блога');
            }
            const serverData = await response.json();
            const normalized = normalize(serverData, postSchema);
            return normalized.entities;
        } catch (error) {
            return rejectWithValue(error.message);
        }
    }
);

const removeProcess = createAsyncThunk(
    'post/removeProcess',
    async function (id, { rejectWithValue, dispatch }) {
        try {
            const response = await fetch(`${API_URL}/${id}`, {
                method: 'DELETE',
            });
            if (!response.ok) {
                throw new Error('Ошибка при удалении поста блога');
            }
            return id;
        } catch (error) {
            return rejectWithValue(error.message);
        }
    }
);

const entityAdapter = createEntityAdapter();

const initialState = entityAdapter.getInitialState({
    loading: false,
    loadError: null,
    status: null,
    error: null,
});

const entitySlice = createSlice({
    name: 'post',
    initialState: initialState,
    reducers: {},
    extraReducers: (builder) => {
        builder
            // загрузка списка постов с сервера
            .addCase(loadProcess.pending, (state, action) => {
                state.loading = true;
                state.loadError = null;
            })
            .addCase(loadProcess.fulfilled, (state, action) => {
                entityAdapter.upsertMany(state, action.payload.posts);
                state.loading = false;
                state.loadError = null;
            })
            .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) => {
                // data = {id: 12, title: 'Пост блога', author: 34, tags: [56,78]}
                const data = Object.values(action.payload.posts)[0];
                entityAdapter.upsertOne(state, data);
                state.error = null;
                state.status = null;
            })
            .addCase(createProcess.rejected, (state, action) => {
                state.error = action.payload;
                state.status = null;
            })
            // обновление поста блога на сервере
            .addCase(updateProcess.pending, (state, action) => {
                state.error = null;
                state.status = 'Обновление поста блога, ждите...';
            })
            .addCase(updateProcess.fulfilled, (state, action) => {
                // data = {id: 12, title: 'Пост блога', author: 34, tags: [56,78]}
                const data = Object.values(action.payload.posts)[0];
                entityAdapter.upsertOne(state, data);
                state.error = null;
                state.status = null;
            })
            .addCase(updateProcess.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) => {
                entityAdapter.removeOne(state, action.payload);
                state.error = null;
                state.status = null;
            })
            .addCase(removeProcess.rejected, (state, action) => {
                state.error = action.payload;
                state.status = null;
            });
    },
});

export { loadProcess, createProcess, updateProcess, removeProcess };

export const selectors = entityAdapter.getSelectors((state) => state.post);

export default entitySlice.reducer;

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

import { createSlice, createEntityAdapter } from '@reduxjs/toolkit';
import { loadProcess, createProcess, updateProcess } from './postSlice.js';

const entityAdapter = createEntityAdapter();

const entitySlice = createSlice({
    name: 'user',
    initialState: entityAdapter.getInitialState(),
    reducers: {},
    extraReducers: {
        [loadProcess.fulfilled]: (state, action) => {
            entityAdapter.upsertMany(state, action.payload.users);
        },
        [createProcess.fulfilled]: (state, action) => {
            const data = Object.values(action.payload.users)[0];
            entityAdapter.upsertOne(state, data);
        },
        [updateProcess.fulfilled]: (state, action) => {
            const data = Object.values(action.payload.users)[0];
            entityAdapter.upsertOne(state, data);
        },
    },
});

export const selectors = entityAdapter.getSelectors((state) => state.user);

export default entitySlice.reducer;

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

import { createSlice, createEntityAdapter } from '@reduxjs/toolkit';
import { loadProcess, createProcess, updateProcess } from './postSlice.js';

const entityAdapter = createEntityAdapter();

const entitySlice = createSlice({
    name: 'tag',
    initialState: entityAdapter.getInitialState(),
    reducers: {},
    extraReducers: {
        [loadProcess.fulfilled]: (state, action) => {
            entityAdapter.upsertMany(state, action.payload.tags);
        },
        [createProcess.fulfilled]: (state, action) => {
            entityAdapter.upsertMany(state, action.payload.tags);
        },
        [updateProcess.fulfilled]: (state, action) => {
            entityAdapter.upsertMany(state, action.payload.tags);
        },
    },
});

export const selectors = entityAdapter.getSelectors((state) => state.tag);

export default entitySlice.reducer;

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

import { schema } from 'normalizr';

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

/*
 * Сущность «Тег (метка)» — это объект с обязательным полем id
 */
const tagSchema = new schema.Entity('tags', {});
const tagListSchema = [tagSchema]; // схема массива тегов

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

export const postListSchema = [postSchema]; // схема массива постов блога
Создаем компоненты

Создаем директорию src/component, внутри нее — компоненты PostList.js, PostItem.js, PostForm.js и Modal.js.

Файл src/App.js:

import './App.css';

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

function App() {
    return (
        <Provider store={store}>
            <div className="App">
                <h1>Все посты блога</h1>
                <PostList />
            </div>
        </Provider>
    );
}

export default App;

Файл src/component/PostList.js:

import { useEffect, useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { loadProcess } from '../redux/postSlice.js';
import { selectors as postSelectors } from '../redux/postSlice.js';
import { PostItem } from './PostItem.js';
import { createProcess, updateProcess, removeProcess } from '../redux/postSlice.js';
import { ModalForm } from './ModalForm.js';

export function PostList(props) {
    const ids = useSelector(postSelectors.selectIds);
    const loading = useSelector((state) => state.post.loading);
    const loadError = useSelector((state) => state.post.loadError);
    const status = useSelector((state) => state.post.status);
    const error = useSelector((state) => state.post.error);

    const dispatch = useDispatch();

    const [showModal, setShowModal] = useState(false); // модальное окно создания/редактирования
    const [editPost, setEditPost] = useState(null); // идентификатор редактируемого поста блога

    // при клике на кнопку «Новый пост»
    const onCreateClick = () => {
        setEditPost(null);
        setShowModal(true);
    };

    // при клике на кнопку «Редактировать»
    const onUpdateClick = (id) => {
        setEditPost(id);
        setShowModal(true);
    };

    // при клике на кнопку «Сохранить»
    const onSaveClick = (data) => {
        let createUpdateProcess = editPost ? updateProcess : createProcess;
        dispatch(createUpdateProcess(data));
        setShowModal(false);
        setEditPost(null);
    };

    // при клике на кнопке закрытия окна
    const onCloseClick = () => {
        setShowModal(false);
        setEditPost(null);
    };

    // при клике на кнопке «Удалить»
    const onRemoveClick = (id) => {
        dispatch(removeProcess(id));
    };

    // загрузка постов с сервера
    useEffect(() => {
        dispatch(loadProcess());
        // eslint-disable-next-line
    }, []);

    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="post-list">
                {ids.length > 0 ? (
                    ids.map((id) => (
                        <PostItem
                            key={id}
                            id={id}
                            onUpdateClick={onUpdateClick}
                            onRemoveClick={onRemoveClick}
                        />
                    ))
                ) : (
                    <p>Список постов пустой</p>
                )}
            </div>
            <ModalForm
                id={editPost}
                show={showModal}
                setShow={setShowModal}
                onCloseClick={onCloseClick}
                onSaveClick={onSaveClick}
            />
            <button onClick={onCreateClick}>Новый пост</button>
        </>
    );
}

Файл src/component/PostItem.js:

import { useSelector } from 'react-redux';
import { selectors as postSelectors } from '../redux/postSlice.js';
import { selectors as userSelectors } from '../redux/userSlice.js';
import { selectors as tagSelectors } from '../redux/tagSlice.js';

export function PostItem(props) {
    const { id, onUpdateClick, onRemoveClick } = props;
    // получаем объект поста блога по идентификатору
    const post = useSelector((state) => postSelectors.selectById(state, id));
    const { title, author, tags } = post;
    // получаем объект автора поста по идентификатору
    const postAuthor = useSelector((state) => userSelectors.selectById(state, author));
    // получаем массив оъектов тегов, привязанных к посту
    const allTags = useSelector(tagSelectors.selectAll);
    const postTags = allTags.filter((tag) => tags.includes(tag.id));

    return (
        <div className="post-item">
            <div>
                <strong>{title}</strong>
            </div>
            <div>
                Автор
                <span className="post-author">{postAuthor.name}</span>
                Метки
                {postTags.map((item) => (
                    <span key={item.id} className="post-tag">
                        {item.name}
                    </span>
                ))}
            </div>
            <div>
                <button className="update" onClick={() => onUpdateClick(id)}>
                    Править
                </button>
                <button className="remove" onClick={() => onRemoveClick(id)}>
                    Удалить
                </button>
            </div>
        </div>
    );
}

Файл src/component/PostForm.js:

import { useState } from 'react';
import { useSelector } from 'react-redux';
import { selectors as userSelectors } from '../redux/userSlice.js';
import { selectors as tagSelectors } from '../redux/tagSlice.js';

export function PostForm(props) {
    // здесь user — это идентификатор автора поста (если идет редактирование),
    // а tags — массив идентификаторов тегов, привязанных к этому посту блога
    const { id = null, title = '', author: user = 'empty', tags = [], onSaveClick } = props;

    const [postTitle, setPostTitle] = useState(title); // текст загловка поста
    const [postUser, setPostUser] = useState(user); // идентификатор автора поста
    const [postTags, setPostTags] = useState(tags); // привязанные к посту теги

    const allUsers = useSelector(userSelectors.selectAll);
    const allTags = useSelector(tagSelectors.selectAll);

    const handleInputChange = (event) => {
        setPostTitle(event.target.value);
    };

    const handleCheckboxChange = (event) => {
        if (event.target.checked) {
            setPostTags([...postTags, event.target.value]);
        } else {
            setPostTags(postTags.filter((item) => item !== event.target.value));
        }
    };

    const handleSelectChange = (event) => {
        setPostUser(event.target.value);
    };

    const isValidFormData = () => {
        if (postTitle.trim() === '') return false;
        if (postUser === 'empty') return false;
        return true;
    };

    const handleFormSubmit = (event) => {
        event.preventDefault();
        if (!isValidFormData()) {
            alert('Ошибка при заполнении формы');
            return;
        }
        // Массив идентификаторов тегов заменяем на массив объектов тегов,
        // а идентификатор автора поста заменяем на объект пользователя,
        // чтобы формат объекта поста соответствовал серверному формату.
        const data = {
            title: postTitle.trim(),
            author: allUsers.find((item) => item.id === postUser),
            tags: allTags.filter((tag) => postTags.includes(tag.id)),
        };
        if (id) data.id = id;
        onSaveClick(data);
    };

    const isCheckedTag = (id) => {
        return !!postTags.find((item) => item === id);
    };

    return (
        <form onSubmit={handleFormSubmit} className="post-form">
            <h3>{id ? 'Редактирование' : 'Создание нового'} поста</h3>
            <div>
                <input
                    type="text"
                    value={postTitle}
                    onChange={handleInputChange}
                    placeholder="Заголовок поста блога"
                />
            </div>
            <div>
                <select value={postUser} onChange={handleSelectChange}>
                    <option value="empty">Выберите автора</option>
                    {allUsers.map((item) => (
                        <option key={item.id} value={item.id}>
                            {item.name}
                        </option>
                    ))}
                </select>
            </div>
            <div>
                {allTags.map((item) => (
                    <label key={item.id}>
                        <input
                            type="checkbox"
                            value={item.id}
                            checked={isCheckedTag(item.id)}
                            onChange={handleCheckboxChange}
                        />
                        {item.name}
                    </label>
                ))}
            </div>
            <input type="submit" value="Сохранить" />
        </form>
    );
}

Файл src/component/Modal.js:

import './Modal.css';

export function Modal(props) {
    if (!props.show) return null;
    return (
        <div className="modal-overlay">
            <div className="modal">
                <span className="modal-close" onClick={props.onCloseClick}>
                    &times;
                </span>
                {props.children}
            </div>
        </div>
    );
}

Файл src/component/ModalForm.js:

import { useSelector } from 'react-redux';
import { selectors as postSelectors } from '../redux/postSlice.js';
import { Modal } from './Modal.js';
import { PostForm } from './PostForm.js';

export function ModalForm(props) {
    const { id, show, onCloseClick, onSaveClick } = props;
    // если мы редактируем пост блога — достаем его из state
    const post = useSelector((state) => postSelectors.selectById(state, id)) ?? {};

    return (
        <Modal show={show} onCloseClick={onCloseClick}>
            <PostForm {...post} onSaveClick={onSaveClick} />
        </Modal>
    );
}

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

$ cd react-redux/react-redux-toolkit-adapter/server
$ npm install
$ npm run start-dev
$ cd react-redux/react-redux-toolkit-adapter/client
$ npm install
$ npm start

RTK Query

RTK Query поставляется как дополнительный модуль в пакете @reduxjs/toolkit. Он специально создан для упрощения запросов к серверу и кэширования полученных от сервера данных. RTK Query включен в установку основного пакета Redux Toolkit. Он доступен через любой из двух вариантов ниже.

import { createApi } from '@reduxjs/toolkit/query'

/* для React приложения */
import { createApi } from '@reduxjs/toolkit/query/react'

RTK Query включает следующие API:

  • createApi() — позволяет определить набор конечных точек для получения данных от сервера
  • fetchBaseQuery() — небольшая оболочка для fetch, которая упрощает запросы к серверу
  • ApiProvider — может использоваться в качестве Provider, если не используется store Redux

Простое приложение

Это будет список задач, которые можно добавлять, удалять и отмечать как завершенные. Нам потребуется сервер, поэтому в директории проекта создаем директории server и client.

Сервер

Нужно установить пакеты express, cors и uuid + dev-зависимость nodemon, чтобы иметь возможность «на лету» редактировать код.

$ cd react-redux/react-redux-toolkit-query/server
$ npm install express --save-prod
$ npm install cors --save-prod
$ npm install uuid --save-prod
$ npm install nodemon --save-dev
{
    "name": "react-redux-toolkit-query-server",
    "version": "0.1.0",
    "private": true,
    "main": "index.js",
    "type": "module",
    "scripts": {
        "start": "node index.js",
        "start-dev": "nodemon index.js"
    },
    "keywords": [],
    "author": "",
    "license": "ISC",
    "dependencies": {
        "cors": "^2.8.5",
        "express": "^4.18.1",
        "uuid": "^9.0.0"
    },
    "devDependencies": {
        "nodemon": "^2.0.20"
    }
}
import express from 'express';
import cors from 'cors';
import { v4 as uuid } from 'uuid';

const todos = [
    {
        id: uuid(),
        title: 'Первая задача',
        completed: false,
    },
    {
        id: uuid(),
        title: 'Вторая задача',
        completed: true,
    },
    {
        id: uuid(),
        title: 'Третья задача',
        completed: false,
    },
    {
        id: uuid(),
        title: 'Четвертая задача',
        completed: true,
    },
    {
        id: uuid(),
        title: 'Пятая задача',
        completed: false,
    },
];

const delay = (ms) => {
    let current = Date.now();
    const future = current + ms;
    while (current < future) {
        current = Date.now();
    }
};

const app = express();
app.use(cors());
app.use(express.json());

// GET-запрос на получение всего списка
app.get('/api/todo', (req, res) => {
    delay(1000);
    res.json(todos);
});

// GET-запрос задачи по идентификатору
app.get('/api/todo/:id', (req, res) => {
    delay(1000);
    const todo = todos.find((item) => item.id === req.params.id);
    if (todo) {
        res.json(todo);
    } else {
        res.status(404).send();
    }
});

// POST-запрос на добавление задачи
app.post('/api/todo', (req, res) => {
    delay(1000);
    const newTodo = {
        id: uuid(),
        title: req.body.title,
        completed: false,
    };
    todos.push(newTodo);
    // возвращаем в ответе новую задачу
    res.json(newTodo);
});

// PUT и PATCH запросы на обновление задачи
const update = (req, res) => {
    delay(1000);
    const id = req.params.id;
    const todo = todos.find((item) => item.id === id);
    if (todo) {
        if (req.body.title !== undefined) todo.title = req.body.title;
        if (req.body.completed !== undefined) todo.completed = req.body.completed;
        // возвращаем в ответе обновленную задачу
        res.json(todo);
    } else {
        res.status(404).send();
    }
};
app.put('/api/todo/:id', update);
app.patch('/api/todo/:id', update);

// DELETE-запрос на удаление задачи
app.delete('/api/todo/:id', (req, res) => {
    delay(1000);
    const index = todos.findIndex((item) => item.id === req.params.id);
    if (index >= 0) {
        const deleted = todos.splice(index, 1);
        // возвращаем в ответе удаленную задачу
        res.json(deleted[0]);
    } else {
        res.status(404).send();
    }
});

app.listen(5000);

Из директории server запускаем сервер:

$ npm run start-dev

На GET-запрос списка задач сервер выдает JSON следующего формата:

[
    {
        "id": "bc541113-34cc-4952-8634-b7d487f80675",
        "title": "Первая задача",
        "completed": false
    },
    {
        "id": "98e4c34a-f15d-4156-b17e-348b7173b408",
        "title": "Вторая задача",
        "completed": true
    },
    {
        "id": "4d227946-a7ae-4919-bf23-6d69bc0cabb6",
        "title": "Третья задача",
        "completed": false
    },
    {
        "id": "dabe9b2c-6489-41e1-b052-0caa4b9ab24f",
        "title": "Четвертая задача",
        "completed": true
    },
    {
        "id": "2ca2dab3-adaf-4648-96ff-221a69a43b3a",
        "title": "Пятая задача",
        "completed": false
    }
]

На POST, PATCH, DELETE запрос сервер выдает созданную, обновленную или удаленную задачу:

{
    "id": "4d227946-a7ae-4919-bf23-6d69bc0cabb6",
    "title": "Третья задача",
    "completed": false
}

Клиент

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

$ cd react-redux/react-redux-toolkit-query/client # можно вот так
$ npx create-react-app .
$ npm install @reduxjs/toolkit --save-prod
$ npm install react-redux --save-prod
$ cd react-redux/react-redux-toolkit-query/client # или вот так
$ npx create-react-app . --template redux
Создаем хранилище

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

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

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

export const store = configureStore({
    reducer: {
        [todoApi.reducerPath]: todoApi.reducer,
    },
    middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(todoApi.middleware),
});

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

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const todoApi = createApi({
    reducerPath: 'todo',
    baseQuery: fetchBaseQuery({ baseUrl: 'http://localhost:5000/api' }),
    endpoints: (builder) => ({ // конечные точки API
        getAllTodo: builder.query({
            query: () => '/todo',
        }),
        getOneTodo: builder.query({
            query: (id) => `/todo/${id}`,
        }),
        createTodo: builder.mutation({
            query: (data) => ({
                url: '/todo',
                method: 'POST',
                body: data,
            }),
        }),
        updateTodo: builder.mutation({
            query: (data) => {
                const { id, ...rest } = data;
                return {
                    url: `/todo/${id}`,
                    method: 'PATCH',
                    body: rest,
                };
            },
        }),
        removeTodo: builder.mutation({
            query: (id) => ({
                url: `/todo/${id}`,
                method: 'DELETE',
            }),
        }),
    }),
});
Создаем компоненты

Создаем директорию src/component, внутри нее — компоненты TodoList.js, TodoItem.js и TodoForm.js.

Файл src/App.js:

import './App.css';

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

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

export default App;

При создании компонентов для получения данных с сервера мы можем использовать несколько хуков:

  • useQuery — объединяет возможности useQueryState и useQuerySubscription и является основным хуком. Автоматически запускает выборку данных из конечной точки, «подписывает» компонент на кешированные данные и считывает статус запроса и кешированные данные из хранилища Redux.
  • useQueryState — возвращает состояние запроса. Получает статус запроса и кэшированные данные из хранилища Redux.
  • useQuerySubscription — возвращает функцию refetch. Автоматически запускает выборку данных из конечной точки и «подписывает» компонент на кэшированные данные из хранилища Redux.

Давайте используем каждый из этих хуков при разработке компонента TodoList.js и посмотрим, как они работают.

import { todoApi } from '../redux/todoApi.js';
import { TodoItem } from './TodoItem.js';

const useGetAllTodoQueryState = todoApi.endpoints.getAllTodo.useQueryState;

export function TodoList(props) {
    const { data, isLoading, isFetching, isSuccess } = useGetAllTodoQueryState(null);

    if (isLoading) return <p>Получение списка задач с сервера...</p>;
    if (!isSuccess) return <p className="error">Не удалось загрузить список</p>;

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

Хук useQueryState не получает данные с сервера, а только берет их из кэша (хранилища Redux). Список задач с сервера мы еще не получали, в кэше этого списка нет— поэтому получаем сообщение об ошибке «Не удалось загрузить список». И на этом работа нашего приложения завершается.

Но мы знаем, что можем запросить данные с сервера с помощью хука useQuerySubscription. Кроме того, хук подпишет компонент на изменение данных кэша — то есть, при изменении состояния запроса будет вызван новый рендер. В процессе загрузки списка с сервера мы сначала увидим сообщение «Получение списка задач», а уже потом — сам список.

import { todoApi } from '../redux/todoApi.js';
import { TodoItem } from './TodoItem.js';

const useGetAllTodoQueryState = todoApi.endpoints.getAllTodo.useQueryState;
const useGetAllTodoQuerySubscription = todoApi.endpoints.getAllTodo.useQuerySubscription;

export function TodoList(props) {
    const { data, isLoading, isFetching, isSuccess } = useGetAllTodoQueryState(null);
    // NEW запускаем выборку данных и подписываем компонент на кэшированные данные
    useGetAllTodoQuerySubscription(null);

    if (isLoading) return <p>Получение списка задач с сервера...</p>;
    if (!isSuccess) return <p className="error">Не удалось загрузить список</p>;

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

Если мы сейчас создадим новую задачу — то будет отправлен POST запрос на сервер, новая задача будет добавлена. Но наш список никак не изменится — только принудительное обновление всей страницы позволяет получить список с новой задачей.

Давайте добавим кнопку, которая будет обновлять список. При нажатии будет отправлен GET запрос на сервер для получения списка задач и этот список будет помещен в кэш (хранилище Redux). И наше приложение покажет обновленный список — уже с новой задачей.

import { todoApi } from '../redux/todoApi.js';
import { TodoItem } from './TodoItem.js';

const useGetAllTodoQueryState = todoApi.endpoints.getAllTodo.useQueryState;
const useGetAllTodoQuerySubscription = todoApi.endpoints.getAllTodo.useQuerySubscription;

export function TodoList(props) {
    const { data, isLoading, isFetching, isSuccess } = useGetAllTodoQueryState(null);
    // NEW получаем функцию refetch, которая позволяет принудительно обновить список
    const { refetch } = useGetAllTodoQuerySubscription(null);

    if (isLoading) return <p>Получение списка задач с сервера...</p>;
    if (!isSuccess) return <p className="error">Не удалось загрузить список</p>;

    return (
        <>
            <div className="todo-list">
                {data.length > 0 ? (
                    data.map((item) => <TodoItem key={item.id} {...item} />)
                ) : (
                    <p>Список задач пустой</p>
                )}
            </div>
            <button onClick={() => refetch()}>Обновить список</button>
            {isFetching && <p>Обновление списка задач...</p>}
        </>
    );
}

Чтобы не обновлять список вручную после добавления новой задачи, можем задать опцию pollingInterval для useGetAllTodoQuerySubscription и обновлять список автоматически каждые две секунды.

import { todoApi } from '../redux/todoApi.js';
import { TodoItem } from './TodoItem.js';

const useGetAllTodoQueryState = todoApi.endpoints.getAllTodo.useQueryState;
const useGetAllTodoQuerySubscription = todoApi.endpoints.getAllTodo.useQuerySubscription;

export function TodoList(props) {
    const { data, isLoading, isFetching, isSuccess } = useGetAllTodoQueryState(null);
    // NEW атоматически обновлять список каждые 2 секунды, выполяняя запрос на сервер
    useGetAllTodoQuerySubscription(null, {
        pollingInterval: 2000,
    });

    if (isLoading) return <p>Получение списка задач с сервера...</p>;
    if (!isSuccess) return <p className="error">Не удалось загрузить список</p>;

    return (
        <>
            <div className="todo-list">
                {data.length > 0 ? (
                    data.map((item) => <TodoItem key={item.id} {...item} />)
                ) : (
                    <p>Список задач пустой</p>
                )}
            </div>
            {isFetching && <p>Обновление списка задач...</p>}
        </>
    );
}

Файл src/component/TodoItem.js:

import { todoApi } from '../redux/todoApi';

const useUpdateTodoMutation = todoApi.endpoints.updateTodo.useMutation;
const useRemoveTodoMutation = todoApi.endpoints.removeTodo.useMutation;

export function TodoItem(props) {
    const { id, title, completed } = props;
    const [updateTodo] = useUpdateTodoMutation();
    const [removeTodo] = useRemoveTodoMutation();

    const handleToggle = () => {
        updateTodo({
            id: id,
            completed: !completed,
        });
    };

    return (
        <div className="todo-item">
            <span>
                <input type="checkbox" checked={completed} onChange={handleToggle} />
                &nbsp;
                <span>{title}</span>
            </span>
            <span className="remove" onClick={() => removeTodo(id)}>
                &times;
            </span>
        </div>
    );
}

Файл src/component/TodoForm.js:

import { useState } from 'react';
import { todoApi } from '../redux/todoApi.js';

const useCreateTodoMutation = todoApi.endpoints.createTodo.useMutation;

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

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

    const handleClick = () => {
        if (text.trim().length !== 0) {
            const data = {
                title: text.trim(),
                completed: false,
            };
            createTodo(data);
        }
        setText('');
    };

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

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

$ cd react-redux/react-redux-toolkit-query/server
$ npm install
$ npm run start-dev
$ cd react-redux/react-redux-toolkit-query/client
$ npm install
$ npm start

Продолжение следует...

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