React и Redux вместе. Часть 7 из 7
15.10.2022
Теги: API • Frontend • Hook • JavaScript • React.js • Web-разработка • Состояние • Список • Теория • Функция
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} /> <span>{data.title}</span> </span> <span className="remove" onClick={() => removeTodo(props.id)}> × </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} /> <span>{data.title}</span> </span> <span className="remove" onClick={() => removeTodo(props.id)}> × </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} /> <span>{currentData.title}</span> </span> <span className="remove" onClick={() => removeTodo(props.id)}> × </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} /> <span>{currentData.title}</span> </span> <span className="remove" onClick={handleRemove}> × </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} /> <span>{data.title}</span> </span> <span className="remove" onClick={() => removeTodo(props.id)}> × </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} /> <span>{data.title}</span> </span> <span className="remove" onClick={() => removeTodo(props.id)}> × </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}> × </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}> × </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-разработка • Состояние • Список • Теория • Функция