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