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

15.10.2022

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

RTK Query

Будем дальше разрабатывать наше приложение списка задач. Потому как сейчас это трудно назвать приложением. И по ходу разработки продолжим знакомиться с RTK Query.

Доработка приложения, часть первая

Первым делом откажемся от использования хуков useQueryState и useQuerySubscription, а будем вместо них использовать хук useQuery.

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

const { data, isLoading, isFetching, isSuccess } = useGetAllTodoQueryState(null);
const { refetch } = useGetAllTodoQuerySubscription(null, {
    pollingInterval: 2000,
});
const useGetAllTodoQuery = todoApi.endpoints.getAllTodo.useQuery;

const { data, isLoading, isFetching, isSuccess, refetch } = useGetAllTodoQuery(null, {
    pollingInterval: 2000,
});

Второе — мы можем получать хук не вот так todoApi.endpoints.getAllTodo.useQuery, а вот так todoApi.useGetAllTodoQuery. Будем экспортировать все нужные для работы хуки в 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) => ({
        getAllTodo: builder.query(/* ... */),
        getOneTodo: builder.query(/* ... */),
        createTodo: builder.mutation(/* ... */),
        updateTodo: builder.mutation(/* ... */),
        removeTodo: builder.mutation(/* ... */),
    }),
});

export const {
    useGetAllTodoQuery,
    useGetOneTodoQuery,
    useCreateTodoMutation,
    useUpdateTodoMutation,
    useRemoveTodoMutation,
} = todoApi;

Третье — усложним себе задачу. Сейчас компонент TodoItem.js получает все необходимое для своей работы через пропсы — id, title, completed. Давайте будем передавать ему только id — а все остальное он будет получать самостоятельно.

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

export function TodoList(props) {
    const { data, isLoading, isSuccess } = useGetAllTodoQuery(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} id={item.id} />)
                ) : (
                    <p>Список задач пустой</p>
                )}
            </div>
        </>
    );
}
import { useGetOneTodoQuery, useUpdateTodoMutation, useRemoveTodoMutation } from '../redux/todoApi';

export function TodoItem(props) {
    const { data, isLoading, isSuccess } = useGetOneTodoQuery(props.id);

    const [updateTodo] = useUpdateTodoMutation();
    const [removeTodo] = useRemoveTodoMutation();

    const handleToggle = () => {
        updateTodo({
            id: props.id,
            title: data.title + ' (updated)',
            completed: !data.completed,
        });
    };

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

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

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

import { useGetOneTodoQuery, useUpdateTodoMutation, useRemoveTodoMutation } from '../redux/todoApi';

export function TodoItem(props) {
    const { data, isLoading, isSuccess, isFetching } = useGetOneTodoQuery(props.id, {
        pollingInterval: 5000,
    });

    const [updateTodo] = useUpdateTodoMutation();
    const [removeTodo] = useRemoveTodoMutation();

    const handleToggle = () => {
        updateTodo({
            id: props.id,
            title: data.title + ' (updated)',
            completed: !data.completed,
        });
    };

    if (isLoading) return <p>Получение задачи {props.id} с сервера...</p>;
    if (!isSuccess) return <p className="error">Не удалось загрузить задачу {props.id}</p>;
    if (isFetching) return <p>Обновление задачи {props.id}...</p>;

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

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

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

Доработка приложения, часть вторая

Наш сервер при обновлении или удалении задачи — возвращает эту обновленную или удаленную задачу. Давайте посмотрим, как можно получить и использовать эти данные.

import { useGetOneTodoQuery, useUpdateTodoMutation, useRemoveTodoMutation } from '../redux/todoApi';

export function TodoItem(props) {
    const { data: loadedData, isLoading, isSuccess } = useGetOneTodoQuery(props.id);

    const [updateTodo, updateResult] = useUpdateTodoMutation();
    const { data: updatedData, isLoading: isUpdating, isSuccess: isUpdated } = updateResult;

    const [removeTodo, removeResult] = useRemoveTodoMutation();
    const { data: removedData, isLoading: isRemoving, isSuccess: isRemoved } = removeResult;

    // данные задачи могут быть как изначально загруженные с сервера, так и уже обновленные
    const currentData = updatedData ?? loadedData;

    const handleToggle = () => {
        updateTodo({
            id: props.id,
            title: currentData.title + ' (updated)',
            completed: !currentData.completed,
        });
    };

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

    if (isRemoving) return <p>Идет удаление задачи {props.id}...</p>;
    if (isRemoved) return <p>Задача {props.id} была удалена</p>;

    if (isUpdating) return <p>Идет обновление задачи {props.id}...</p>;

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

Хуки useUpdateTodoMutation и useRemoveTodoMutation позволяют не только получить функцию для обновления и удаления задачи, но и результаты PATCH и DELETE запросов. И можем отследить, когда запрос еще выполняется — это isLoading и когда успешно завершен — это isSuccess. После успешного выполнения запроса нам доступны данные, полученные от сервера — это data.

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

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

Доработка приложения, часть третья

Еще один вариант реализации компонента TodoItem.js — без использования объектов updateResult и removeResult.

const [updateTodo, updateResult] = useUpdateTodoMutation();
const { data: updatedData, isLoading: isUpdating, isSuccess: isUpdated } = updateResult;

const [removeTodo, removeResult] = useRemoveTodoMutation();
const { data: removedData, isLoading: isRemoving, isSuccess: isRemoved } = removeResult;
import { useGetOneTodoQuery, useUpdateTodoMutation, useRemoveTodoMutation } from '../redux/todoApi';
import { useState } from 'react';

const initState = {
    data: null,
    isLoading: false,
    isSuccess: false,
    error: null,
};

export function TodoItem(props) {
    const { data: loadedData, isLoading, isSuccess } = useGetOneTodoQuery(props.id);

    const [updateTodo] = useUpdateTodoMutation();
    const [updateState, setUpdateState] = useState(initState);

    const [removeTodo] = useRemoveTodoMutation();
    const [removeState, setRemoveState] = useState(initState);

    // данные задачи могут быть как изначально загруженные с сервера, так и уже обновленные
    const currentData = updateState.data ?? loadedData;

    const handleUpdate = () => {
        setUpdateState({
            ...initState,
            isLoading: true,
        });
        updateTodo({
            id: props.id,
            title: currentData.title + ' (updated)',
            completed: !currentData.completed,
        })
            .unwrap()
            .then((data) => {
                setUpdateState({
                    ...updateState,
                    isLoading: false,
                    isSuccess: true,
                    data: data,
                });
            })
            .catch((error) => {
                setUpdateState({
                    ...updateState,
                    isLoading: false,
                    isSuccess: false,
                    error: error,
                });
            });
    };

    const handleRemove = () => {
        setRemoveState({
            ...initState,
            isLoading: true,
        });
        removeTodo(props.id)
            .unwrap()
            .then((data) => {
                setRemoveState({
                    ...removeState,
                    isLoading: false,
                    isSuccess: true,
                    data: data,
                });
            })
            .catch((error) => {
                setRemoveState({
                    ...removeState,
                    isLoading: false,
                    isSuccess: false,
                    error: error,
                });
            });
    };

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

    if (removeState.isLoading) return <p>Идет удаление задачи {props.id}...</p>;
    if (removeState.isSuccess) return <p>Задача {props.id} была удалена</p>;

    if (updateState.isLoading) return <p>Идет обновление задачи {props.id}...</p>;

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

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

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

Доработка приложения, часть четвертая

RTK Query предлагает систему «кеш-тегов» для автоматизации повторного получения с сервера тех данных, которые были затронуты мутацией. То есть, если мы обновили пост блога — надо обновить страницу списка постов и страницу просмотра этого поста. Для этого нужно задать значения cacheType, providesTags и invalidatesTags.

Кеш-теги — это просто метки, которые можно присвоить определенному набору данных для управления поведением кэширования и аннулирования с целью повторной выборки. Типы тегов задаются в tagTypes при определении API. Если наше приложение работает с задачами и пользователями, то tagTypes будет иметь вид ['todo', 'user'].

Теги прикрепляются к GET-запросам на выборку данных, например — запрос на получение списка сущностей или отдельной сущности (задача, пост блога, товар). Прикрепленные теги providesTags — это массив объектов типа {type: string, id?: string|number}.

При изменении данных, то есть при выполнении мутации — мы можем указать, какие данные надо аннулировать с целью их повтороной выборки. Для POST, PATCH, DELETE запросов нужно указать invalidatesTags — это массив объектов типа {type: string, id?: string|number}.

Значением providesTags и invalidatesTags может быть не только массив объектов тегов, но и функция, которая возвращает такой массив. В качестве аргументов она принимает результат запроса (что мы получаем от сервера), объект ошибки (если запрос завершился неудачей) и данные запроса (что мы отправляем на сервер).

import { useGetOneTodoQuery, useUpdateTodoMutation, useRemoveTodoMutation } from '../redux/todoApi';

export function TodoItem(props) {
    const { data, isFetching, isSuccess } = useGetOneTodoQuery(props.id);

    const [updateTodo, { isLoading: isUpdating }] = useUpdateTodoMutation();
    const [removeTodo, { isLoading: isRemoving }] = useRemoveTodoMutation();

    const handleToggle = () => {
        updateTodo({
            id: props.id,
            title: data.title + ' (updated)',
            completed: !data.completed,
        });
    };

    if (isFetching) return <p className="info">Получение задачи {props.id} с сервера...</p>;
    if (!isSuccess) return <p className="error">Не удалось загрузить задачу {props.id}</p>;
    if (isUpdating) return <p className="info">Обновление задачи {props.id} на сервере...</p>;
    if (isRemoving) return <p className="info">Удаление задачи {props.id} на сервере</p>;

    return (
        <div className="todo-item">
            <span>
                <input type="checkbox" checked={data.completed} onChange={handleToggle} />
                &nbsp;
                <span>{data.title}</span>
            </span>
            <span className="remove" onClick={() => removeTodo(props.id)}>
                &times;
            </span>
        </div>
    );
}
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const todoApi = createApi({
    reducerPath: 'todo',
    baseQuery: fetchBaseQuery({ baseUrl: 'http://localhost:5000/api' }),
    tagTypes: ['todo'],
    endpoints: (builder) => ({
        getAllTodo: builder.query({
            query: () => '/todo',
            providesTags: [{ type: 'todo', id: 'list' }],
        }),
        getOneTodo: builder.query({
            query: (id) => `/todo/${id}`,
            providesTags: (res, err, arg) => [{ type: 'todo', id: arg }],
        }),
        createTodo: builder.mutation({
            query: (data) => ({
                url: '/todo',
                method: 'POST',
                body: data,
            }),
            invalidatesTags: [{ type: 'todo', id: 'list' }],
        }),
        updateTodo: builder.mutation({
            query: (data) => {
                const { id, ...rest } = data;
                return {
                    url: `/todo/${id}`,
                    method: 'PATCH',
                    body: rest,
                };
            },
            invalidatesTags: (res, err, arg) => [{ type: 'todo', id: arg.id }],
        }),
        removeTodo: builder.mutation({
            query: (id) => ({
                url: `/todo/${id}`,
                method: 'DELETE',
            }),
            invalidatesTags: [{ type: 'todo', id: 'list' }],
        }),
    }),
});

export const {
    useGetAllTodoQuery,
    useGetOneTodoQuery,
    useCreateTodoMutation,
    useUpdateTodoMutation,
    useRemoveTodoMutation,
} = todoApi;

При добавлении и удалении задачи мы аннулируем кэш списка — потому что изменилось количество задач. При изменении задачи мы аннулируем кэш этой задачи, чтобы компонент TodoItem.js запросил ее с сервера.

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

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

Доработка приложения, часть пятая

Предыдущая версия нашего приложения не слишком удачная. При удалении задачи мы можем вообще не аннулировать кэш, потому что к нему больше не будет обращений. А компонент TodoItem.js просто будет возвращать null для удаленной задачи.

import { useGetOneTodoQuery, useUpdateTodoMutation, useRemoveTodoMutation } from '../redux/todoApi';

export function TodoItem(props) {
    const { data, isFetching, isSuccess } = useGetOneTodoQuery(props.id);

    const [updateTodo, { isLoading: isUpdating }] = useUpdateTodoMutation();
    const [removeTodo, { isLoading: isRemoving, isSuccess: isRemoved }] = useRemoveTodoMutation();

    const handleToggle = () => {
        updateTodo({
            id: props.id,
            title: data.title + ' (updated)',
            completed: !data.completed,
        });
    };

    if (isRemoved) return null; // NEW если задача удалена

    if (isFetching) return <p className="info">Получение задачи {props.id} с сервера...</p>;
    if (!isSuccess) return <p className="error">Не удалось загрузить задачу {props.id}</p>;
    if (isUpdating) return <p className="info">Обновление задачи {props.id} на сервере...</p>;
    if (isRemoving) return <p className="info">Удаление задачи {props.id} на сервере</p>;

    return (
        <div className="todo-item">
            <span>
                <input type="checkbox" checked={data.completed} onChange={handleToggle} />
                &nbsp;
                <span>{data.title}</span>
            </span>
            <span className="remove" onClick={() => removeTodo(props.id)}>
                &times;
            </span>
        </div>
    );
}
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const todoApi = createApi({
    reducerPath: 'todo',
    baseQuery: fetchBaseQuery({ baseUrl: 'http://localhost:5000/api' }),
    tagTypes: ['todo'],
    endpoints: (builder) => ({
        getAllTodo: builder.query({
            query: () => '/todo',
            providesTags: [{ type: 'todo', id: 'list' }],
        }),
        getOneTodo: builder.query({
            query: (id) => `/todo/${id}`,
            providesTags: (res, err, arg) => [{ type: 'todo', id: arg }],
        }),
        createTodo: builder.mutation({
            query: (data) => ({
                url: '/todo',
                method: 'POST',
                body: data,
            }),
            invalidatesTags: [{ type: 'todo', id: 'list' }],
        }),
        updateTodo: builder.mutation({
            query: (data) => {
                const { id, ...rest } = data;
                return {
                    url: `/todo/${id}`,
                    method: 'PATCH',
                    body: rest,
                };
            },
            invalidatesTags: (res, err, arg) => [{ type: 'todo', id: arg.id }],
        }),
        removeTodo: builder.mutation({
            query: (id) => ({
                url: `/todo/${id}`,
                method: 'DELETE',
            }),
            // NEW при удалении задачи не надо ничего запрашивать с сервера
        }),
    }),
});

export const {
    useGetAllTodoQuery,
    useGetOneTodoQuery,
    useCreateTodoMutation,
    useUpdateTodoMutation,
    useRemoveTodoMutation,
} = todoApi;

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

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

Доработка приложения, часть шестая

Давайте еще немного усложним наше приложение. Пусть у задачи кроме id, title, complete будут еще поле content. Тогда конечная точка getAllTodo будет отдавать title и complete, а конечная точка getOneTodo — все поля задачи. Можно будет просматривать как весь список задач, так и отдельную задачу. И посмотрим, как правильно задать значения providesTags и invalidatesTags.

import express from 'express';
import cors from 'cors';
import { v4 as uuid } from 'uuid';

const todos = [
    {
        id: uuid(),
        title: 'Первая задача',
        content: 'Описание первой задачи',
        completed: false,
    },
    {
        id: uuid(),
        title: 'Вторая задача',
        content: 'Описание второй задачи',
        completed: true,
    },
    {
        id: uuid(),
        title: 'Третья задача',
        content: 'Описание третьей задачи',
        completed: false,
    },
    {
        id: uuid(),
        title: 'Четвертая задача',
        content: 'Описание четвертой задачи',
        completed: true,
    },
    {
        id: uuid(),
        title: 'Пятая задача',
        content: 'Описание пятой задачи',
        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);
    const short = todos.map(({content, ...rest}) => rest);
    res.json(short);
});

// 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,
        content: req.body.content,
        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.content !== undefined) todo.content = req.body.content;
        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);

Компонент TodoList.js отвечает за показ списка задач, а компонент TodoView.js — за показ отдельной задачи. Значение view слайса состояния user будет хранить идентификатор просматриваемой задачи или null.

Файл src/App.js:

import './App.css';

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

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

export default App;

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

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

const initialState = {
    view: null, // идентификатор просматриваемой пользователем задачи
    edit: null, // идентификатор редактируемой пользователем задачи
};

export const userSlice = createSlice({
    name: 'user',
    initialState: initialState,
    reducers: {
        setView(state, action) {
            state.view = action.payload;
        },
        setEdit(state, action) {
            state.edit = action.payload;
        },
    },
});

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

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

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

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

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

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

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

    if (isFetching) return (
        <div className="todo-list">
            <span className="info">Получение списка задач с сервера...</span>
        </div>
    );
    if (!isSuccess) return (
        <div className="todo-list">
            <span className="error">Не удалось загрузить список</span>
        </div>
    );

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

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

import { useGetOneTodoQuery, useUpdateTodoMutation, useRemoveTodoMutation } from '../redux/todoApi';
import { useSelector, useDispatch } from 'react-redux';
import { setView, setEdit } from '../redux/userSlice';

export function TodoItem({ id }) {
    const { data, isFetching, isSuccess } = useGetOneTodoQuery(id);

    // при удалении задачи мы должны будем установить в null значение id просматриваемой или
    // редактируемой задачи, если это просматривается или редактируется именно эта задача
    const viewTodo = useSelector((state) => state.user.view);
    const editTodo = useSelector((state) => state.user.edit);

    const dispatch = useDispatch();

    const [updateTodo, { isLoading: isUpdating }] = useUpdateTodoMutation();
    const [removeTodo, { isLoading: isRemoving, isSuccess: isRemoved }] = useRemoveTodoMutation();

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

    const handleRemove = () => {
        removeTodo(id);
        if (id === viewTodo) {
            dispatch(setView(null));
        }
        if (id === editTodo) {
            dispatch(setEdit(null));
        }
    };

    if (isRemoved) return null;

    if (isFetching) return (
        <div className="todo-item">
            <span className="info">Получение задачи {id} с сервера...</span>
        </div>
    );
    if (!isSuccess) return (
        <div className="todo-item">
            <span className="error">Не удалось загрузить задачу {id}</span>
        </div>
    );

    if (isUpdating) return (
        <div className="todo-item">
            <span className="info">Обновление задачи {id} на сервере...</span>
        </div>
    );
    if (isRemoving) return (
        <div className="todo-item">
            <span className="info">Удаление задачи {id} на сервере...</span>
        </div>
    );

    return (
        <div className="todo-item">
            <span>
                <input type="checkbox" checked={data.completed} onChange={handleToggle} />
                <span>{data.title}</span>
                <span>
                    <button onClick={() => dispatch(setView(id))}>View</button>
                    <button onClick={() => dispatch(setEdit(id))}>Edit</button>
                </span>
            </span>
            <span className="remove" onClick={handleRemove}>
                &times;
            </span>
        </div>
    );
}

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

import { useGetOneTodoQuery } from '../redux/todoApi';
import { useSelector, useDispatch } from 'react-redux';
import { setView } from '../redux/userSlice';

export function TodoView() {
    // идентификатор просматриваемой задачи или null
    const id = useSelector((state) => state.user.view);
    const dispatch = useDispatch();
    // задачу получаем, только если id не равен null
    const { data, isFetching, isSuccess } = useGetOneTodoQuery(id, { skip: id ? false : true });

    if (id === null) return null;

    if (isFetching) return (
        <div className="todo-view">
            <span className="info">Получение задачи {id} с сервера...</span>
        </div>
    )
    if (!isSuccess) return (
        <div className="todo-view">
            <span className="error">Не удалось получить задачу {id}</span>
        </div>
    )

    return (
        <div className="todo-view">
            <h3>{data.title}</h3>
            <p>Статус: {!data.completed && 'не'} завершена</p>
            <p>Описание: {data.content}</p>
            <button onClick={() => dispatch(setView(null))}>Отменить просмотр</button>
        </div>
    );
}

Здесь нам интересно, как задать правильные значения providesTags и invalidatesTags. При создании новой задачи нужно аннулировать кэш списка — потому что изменилось количество задач. При обновлении задачи — нужно аннулировать кэш этой задачи, чтобы компоненты TodoItem.js и TodoView.js получили обновленные данные. При удалении задачи не нужно вообще аннулировать кэш задачи, потому что к нему больше не будет обращений. Каждый экземпляр компонента TodoItem.js запрашивает с сервера все данные отдельной задачи. А компонент TodoView.js будет получать данные задачи для просмотра уже из кэша.

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

export const todoApi = createApi({
    reducerPath: 'todo',
    baseQuery: fetchBaseQuery({ baseUrl: 'http://localhost:5000/api' }),
    tagTypes: ['todo'],
    endpoints: (builder) => ({
        getAllTodo: builder.query({
            query: () => '/todo',
            providesTags: (res = [], err, arg) => [
                { type: 'todo', id: 'list' },
            ],
        }),
        getOneTodo: builder.query({
            query: (id) => `/todo/${id}`,
            providesTags: (res, err, arg) => [{ type: 'todo', id: arg }],
        }),
        createTodo: builder.mutation({
            query: (data) => ({
                url: '/todo',
                method: 'POST',
                body: data,
            }),
            invalidatesTags: [{ type: 'todo', id: 'list' }],
        }),
        updateTodo: builder.mutation({
            query: (data) => {
                const { id, ...rest } = data;
                return {
                    url: `/todo/${id}`,
                    method: 'PATCH',
                    body: rest,
                };
            },
            invalidatesTags: (res, err, arg) => [
                { type: 'todo', id: arg.id },
            ],
        }),
        removeTodo: builder.mutation({
            query: (id) => ({
                url: `/todo/${id}`,
                method: 'DELETE',
            }),
        }),
    }),
});

export const {
    useGetAllTodoQuery,
    useGetOneTodoQuery,
    useCreateTodoMutation,
    useUpdateTodoMutation,
    useRemoveTodoMutation,
} = todoApi;

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

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

Доработка приложения, часть седьмая

Задач может быть много — так что нашему приложению требуется постраничная навигация. И пусть компонент TodoList.js будет передавать компоненту TodoItem.js все данные по задаче, которые у него есть — то есть id, title и completed. Так что компоненту TodoItem.js не надо будет получать их с сервера самостоятельно — это ближе к реальной ситуации при разработке такого приложения. Получать все данные задачи для просмотра пользователем (включая content) будет компонент TodoView.js — когда в этом появится необходимость.

Для этого внесем изменения в код сервера server/index.js:

import express from 'express';
import cors from 'cors';
import { v4 as uuid } from 'uuid';

const todos = [
    {
        id: uuid(),
        title: 'Первая задача',
        content: 'Описание первой задачи',
        completed: false,
    },
    {
        id: uuid(),
        title: 'Вторая задача',
        content: 'Описание второй задачи',
        completed: true,
    },
    {
        id: uuid(),
        title: 'Третья задача',
        content: 'Описание третьей задачи',
        completed: false,
    },
    {
        id: uuid(),
        title: 'Четвертая задача',
        content: 'Описание четвертой задачи',
        completed: true,
    },
    {
        id: uuid(),
        title: 'Пятая задача',
        content: 'Описание пятой задачи',
        completed: false,
    },
    {
        id: uuid(),
        title: 'Шестая задача',
        content: 'Описание шестой задачи',
        completed: true,
    },
    {
        id: uuid(),
        title: 'Седьмая задача',
        content: 'Описание седьмой задачи',
        completed: false,
    },
];

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

const PAGE_SIZE = 3;

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

// GET-запрос на получение всего списка
app.get('/api/todo', (req, res) => {
    delay(1000);
    const page = req.query.page && /^\d+$/.test(req.query.page) ? parseInt(req.query.page) : 1;
    const pages = todos.length > 0 ? Math.ceil(todos.length / PAGE_SIZE) : 1;
    if (page <= pages) {
        const start = (page - 1) * PAGE_SIZE;
        const slice = todos.slice(start, start + PAGE_SIZE);
        const short = slice.map(({ content, ...rest }) => rest);
        res.json(short);
    } else {
        res.status(404).send();
    }
});

// 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,
        content: req.body.content,
        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.content !== undefined) todo.content = req.body.content;
        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);

Файл client/src/App.js:

import './App.css';

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

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

export default App;

Файл client/src/component/TodoList.js:

import { useSelector } from 'react-redux';
import { useGetAllTodoQuery } from '../redux/todoApi';
import { TodoItem } from './TodoItem';

export function TodoList(props) {
    const page = useSelector((state) => state.user.page);
    const { data, isFetching, isSuccess } = useGetAllTodoQuery({ page: page });

    if (isFetching) return (
        <div className="todo-list">
            <span className="info">Получение списка задач с сервера...</span>
        </div>
    );
    if (!isSuccess) return (
        <div className="todo-list">
            <span className="error">Не удалось загрузить список</span>
        </div>
    );

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

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

import { useGetOneTodoQuery, useUpdateTodoMutation, useRemoveTodoMutation } from '../redux/todoApi';
import { useSelector, useDispatch } from 'react-redux';
import { setView, setEdit } from '../redux/userSlice';

export function TodoItem(props) {
    const { id, title, completed } = props;

    // при удалении задачи мы должны будем установить в null значение id просматриваемой или
    // редактируемой задачи, если это просматривается или редактируется именно эта задача
    const viewTodo = useSelector((state) => state.user.view);
    const editTodo = useSelector((state) => state.user.edit);

    const dispatch = useDispatch();

    const [updateTodo, { isLoading: isUpdating }] = useUpdateTodoMutation();
    const [removeTodo, { isLoading: isRemoving }] = useRemoveTodoMutation();

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

    const handleRemove = () => {
        removeTodo(id);
        if (id === viewTodo) {
            dispatch(setView(null));
        }
        if (id === editTodo) {
            dispatch(setEdit(null));
        }
    };

    if (isUpdating) return (
        <div className="todo-item">
            <span className="info">Обновление задачи {id} на сервере...</span>
        </div>
    );
    if (isRemoving) return (
        <div className="todo-item">
            <span className="info">Удаление задачи {id} на сервере...</span>
        </div>
    );

    return (
        <div className="todo-item">
            <span>
                <input type="checkbox" checked={completed} onChange={handleToggle} />
                <span>{title}</span>
                <span>
                    <button onClick={() => dispatch(setView(id))}>View</button>
                    <button onClick={() => dispatch(setEdit(id))}>Edit</button>
                </span>
            </span>
            <span className="remove" onClick={handleRemove}>
                &times;
            </span>
        </div>
    );
}

Файл client/src/component/Pager.js:

import { useGetAllTodoQuery } from '../redux/todoApi';
import { useSelector, useDispatch } from 'react-redux';
import { setPage } from '../redux/userSlice';
import { useEffect } from 'react';

export function Pager(props) {
    // какая страница сейчас показывается
    const page = useSelector((state) => state.user.page);
    const dispatch = useDispatch();

    // если на последней странице были удалены все задачи — нужно прейти на предыдущую страницу
    const { isError } = useGetAllTodoQuery({ page: page });
    useEffect(() => {
        isError && page > 1 && dispatch(setPage(page - 1));
    }, [isError]);

    // если есть следующая страница — мы можем показывать кнопку «Next» для перехода на нее
    const { isFetching, isSuccess } = useGetAllTodoQuery({ page: page + 1 });
    const hasNextPage = !isFetching && isSuccess;

    return (
        <div className="pager">
            {page > 1 && <button onClick={() => dispatch(setPage(page - 1))}>Prev</button>}
            <strong>{page}</strong>
            {hasNextPage && <button onClick={() => dispatch(setPage(page + 1))}>Next</button>}
        </div>
    );
}

Файл client/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' }),
    tagTypes: ['todo'],
    endpoints: (builder) => ({
        getAllTodo: builder.query({
            query: ({ page }) => `/todo?page=${page}`,
            providesTags: (res = [], err, arg) => [
                ...res.map((item) => ({ type: 'todo', id: item.id })),
                { type: 'todo', id: 'list' },
            ],
        }),
        getOneTodo: builder.query({
            query: (id) => `/todo/${id}`,
            providesTags: (res, err, arg) => [{ type: 'todo', id: arg }],
        }),
        createTodo: builder.mutation({
            query: (data) => ({
                url: '/todo',
                method: 'POST',
                body: data,
            }),
            invalidatesTags: [{ type: 'todo', id: 'list' }],
        }),
        updateTodo: builder.mutation({
            query: (data) => {
                const { id, ...rest } = data;
                return {
                    url: `/todo/${id}`,
                    method: 'PATCH',
                    body: rest,
                };
            },
            invalidatesTags: (res, err, arg) => [
                { type: 'todo', id: arg.id },
                { type: 'todo', id: 'list' },
            ],
        }),
        removeTodo: builder.mutation({
            query: (id) => ({
                url: `/todo/${id}`,
                method: 'DELETE',
            }),
            invalidatesTags: (res, err, arg) => [
                { type: 'todo', id: arg },
                { type: 'todo', id: 'list' },
            ],
        }),
    }),
});

export const {
    useGetAllTodoQuery,
    useGetOneTodoQuery,
    useCreateTodoMutation,
    useUpdateTodoMutation,
    useRemoveTodoMutation,
} = todoApi;

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

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

const initialState = {
    view: null, // идентификатор просматриваемой пользователем задачи
    edit: null, // идентификатор редактируемой пользователем задачи
    page: 1,    // номер текущей страницы списка задач при пагинации
};

export const userSlice = createSlice({
    name: 'user',
    initialState: initialState,
    reducers: {
        setView(state, action) {
            state.view = action.payload;
        },
        setEdit(state, action) {
            state.edit = action.payload;
        },
        setPage(state, action) {
            state.page = action.payload;
        },
    },
});

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

Наш список задач может быть отфильтрован и отсортирован. Так что при добавлении, изменении и удалении задачи — мы аннулируем кэш списка. Потому что не можем предсказать, как изменится текущая страница списка задач при любом из этих действий. Например, мы находимся на второй странице и изменили задачу. Эта задача может вообще пропасть из списка, потому что он отфильтрован по значению completed. Или, задача может переместиться на первую или третью страницу, потому что список отсортирован по title.

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

$ cd react-redux/react-redux-toolkit-query-seven/server
$ npm install
$ npm run start-dev
$ cd react-redux/react-redux-toolkit-query-seven/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.