React.js. Модуль React Query. Часть 2 из 2
03.10.2021
Теги: API • Frontend • JavaScript • React.js • Web-разработка • Блог • Запрос • Модуль • Сервер
Запросы для пагинации
Для реализации пагинации с помощью React Query достаточно включить информацию о текущей странице в ключ запроса. И добавить на страницу списка постов информацию о текущей странице и кнопки для перехода к следующей и предыдущей. Кроме того, нам надо изменить код сервера, чтобы он возвращал не все посты блога, а только их часть — с этого и начнем.
import express from 'express'; import cors from 'cors'; import parser from 'body-parser'; import { v4 as uuidv4 } from 'uuid'; const posts = [ { uuid: uuidv4(), title: 'Первый пост блога', content: 'Контент первого поста блога', author: 'Сергей Иванов', }, { uuid: uuidv4(), title: 'Второй пост блога', content: 'Контент второго поста блога', author: 'Николай Смирнов', }, { uuid: uuidv4(), title: 'Третий пост блога', content: 'Контент третьего поста блога', author: 'Сергей Иванов', }, { uuid: uuidv4(), title: 'Четвертый пост блога', content: 'Контент четвертого поста блога', author: 'Николай Смирнов', }, { uuid: uuidv4(), title: 'Пятый пост блога', content: 'Контент пятого поста блога', author: 'Андрей Петров', }, { uuid: uuidv4(), title: 'Шестой пост блога', content: 'Контент шестого поста блога', author: 'Сергей Иванов', }, { uuid: uuidv4(), title: 'Седьмой пост блога', content: 'Контент седьмого поста блога', author: 'Николай Смирнов', }, { uuid: uuidv4(), title: 'Восьмой пост блога', content: 'Контент восьмого поста блога', author: 'Сергей Иванов', }, { uuid: uuidv4(), title: 'Девятый пост блога', content: 'Контент девятого поста блога', author: 'Николай Смирнов', }, { uuid: uuidv4(), title: 'Десятый пост блога', content: 'Контент десятого поста блога', author: 'Андрей Петров', } ]; const delay = (ms) => { let current = Date.now(); const future = current + ms; while (current < future) { current = Date.now(); } }; const PAGE_SIZE = 3; const getPagePosts = (page = 1) => { if (isNaN(page)) return null; if (page < 1) return null; if (posts.length === 0) { return { totalPosts: 0, totalPages: 1, currentPage: 1, hasNextPage: false, hasPrevPage: false, posts: [], }; } const totalPages = Math.ceil(posts.length / PAGE_SIZE); if (page > totalPages) return null; const start = (page - 1) * PAGE_SIZE; const slice = posts.slice(start, start + PAGE_SIZE); const hasPrevPage = (page === 1) ? false : true; const hasNextPage = (page === totalPages) ? false : true; return { totalPosts: posts.length, totalPages: totalPages, currentPage: page, hasPrevPage: hasPrevPage, hasNextPage: hasNextPage, posts: slice, } } const app = express(); app.use(cors()); app.use(parser.json()); // GET-запрос на получение списка постов app.get('/api/list', (req, res) => { // задержка с ответом 2 секунды delay(2000); const result = getPagePosts(parseInt(req.query.page)); if (result !== null) { res.json(result); } else { res.status(404).send(); } }); // GET-запрос поста блога по идентификатору app.get('/api/post/:uuid', (req, res) => { // задержка с ответом 2 секунды delay(2000); const post = posts.find(item => item.uuid === req.params.uuid); if (post) { res.json(post); } else { res.status(404).send(); } }); app.listen(5000);
Пример запроса второй страницы:
http://localhost:5000/api/list?page=2
{ "totalPosts":10, "totalPages":4, "currentPage":2, "hasPrevPage":true, "hasNextPage":true, "posts":[ { "uuid":"d6c629e3-c486-40c7-ab54-803392bfe795", "title":"Четвертый пост блога", "content":"Контент четвертого поста блога", "author":"Николай Смирнов" }, { "uuid":"471ea6b6-a10d-4ad7-b310-d7ce7d6a0773", "title":"Пятый пост блога", "content":"Контент пятого поста блога", "author":"Андрей Петров" }, { "uuid":"7c7371a4-d67a-4b01-85d8-8cfe57b7c532", "title":"Шестой пост блога", "content":"Контент шестого поста блога", "author":"Сергей Иванов" } ] }
Теперь изменяем код компонента List
:
import axios from 'axios'; import { Link } from 'react-router-dom'; import { useQuery } from 'react-query'; import { useEffect, useState } from 'react'; import { useHistory, useLocation } from 'react-router'; const fetchList = async (page = 1) => { const response = await axios.get('http://localhost:5000/api/list?page=' + page); return response.data; } export default function List() { const history = useHistory(); const location = useLocation(); // При монтировании компонента мы не знаем, какую страницу загружать, поэтому // получаем ее из адресной строки браузера — «/», «/?page=2», «/?page=3» const getStartPage = () => { const match = location.search.match(/page=[0-9]+/); if (match && match.length) { const [, page] = match[0].split('='); return parseInt(page, 10); } return 1; }; const [page, setPage] = useState(getStartPage); // Вспомогательная функция, которая добавляет записи в историю браузера // при нажатии кнопок «Предыдущая» и «Следующая» — тогда будут работать // и кнопки браузера «Вперед» и «Назад» const pushState = (shift) => { const newPage = page + shift; if (newPage !== 1) { history.push('/?page=' + newPage, {page: newPage}); } else { history.push('/', {page: 1}); } } const handlePrevClick = () => { pushState(-1); setPage(page => page - 1); }; const handleNextClick = () => { pushState(1); setPage(page => page + 1); }; // Следим за изменением адреса страницы, то есть «/», «/?page=2», «/?page=3». // Если изменение адреса вызвано нажатием кнопок браузера «Вперед» и «Назад», // тогда изменяем состояние, чтобы вызвать новый рендер, выполнить запрос к // API-серверу (или взять данные из кэша) и показать страницу из истории. useEffect(() => { const unbind = history.listen((location) => { if (history.action === 'POP') { const oldPage = location.state?.page; setPage(oldPage ? oldPage : 1); } }); return () => unbind(); // eslint-disable-next-line }, []); const { status, data, isFetching, } = useQuery(['list', {page: page}], () => fetchList(page)); if (status === 'loading') { return <p>Идет загрузка данных...</p> } if (status === 'error') { return <p>Что-то пошло не так...</p> } return ( <div> {data.posts.length ? ( <> <h1>Все посты блога</h1> <ul> {data.posts.map(post => ( <li key={post.uuid}> <Link to={'/post/' + post.uuid}>{post.title}</Link> </li> ))} </ul> {data.hasPrevPage && <button onClick={handlePrevClick}>Предыдущая</button>} <strong> страница {page} </strong> {data.hasNextPage && <button onClick={handleNextClick}>Следующая</button>} </> ) : ( <p>Нет постов</p> )} {isFetching && <p>Фоновое обновление данных...</p>} </div> ); }
Если запустить этот пример, то можно заметить, что после клика на кнопку «Следующая» — появляется текст «Идет загрузка данных...». Но можно это изменить, если использовать настройку keepPreviousData
. В этом случае старая порция данных будет показываться до тех пор, пока не будут получена новая порция (следующая страница) — после чего старая порция данных мягко заменяется на новую. С помощью isPreviousData
мы можем определить — какие именно данные сейчас в data
— новые или старые.
import axios from 'axios'; import { Link } from 'react-router-dom'; import { useQuery } from 'react-query'; import { useEffect, useState } from 'react'; import { useHistory, useLocation } from 'react-router'; const fetchList = async (page = 1) => { const response = await axios.get('http://localhost:5000/api/list?page=' + page); return response.data; } export default function List() { const history = useHistory(); const location = useLocation(); // При монтировании компонента мы не знаем, какую страницу загружать, поэтому // получаем ее из адресной строки браузера — «/», «/?page=2», «/?page=3» const getStartPage = () => { const match = location.search.match(/page=[0-9]+/); if (match && match.length) { const [, page] = match[0].split('='); return parseInt(page, 10); } return 1; }; const [page, setPage] = useState(getStartPage); // Вспомогательная функция, которая добавляет записи в историю браузера // при нажатии кнопок «Предыдущая» и «Следующая» — тогда будут работать // и кнопки браузера «Вперед» и «Назад» const pushState = (shift) => { const newPage = page + shift; if (newPage !== 1) { history.push('/?page=' + newPage, {page: newPage}); } else { history.push('/', {page: 1}); } } const handlePrevClick = () => { pushState(-1); setPage(page => page - 1); }; const handleNextClick = () => { pushState(1); setPage(page => page + 1); }; // Следим за изменением адреса страницы, то есть «/», «/?page=2», «/?page=3». // Если изменение адреса вызвано нажатием кнопок браузера «Вперед» и «Назад», // тогда изменяем состояние, чтобы вызвать новый рендер, выполнить запрос к // API-серверу (или взять данные из кэша) и показать страницу из истории. useEffect(() => { const unbind = history.listen((location) => { if (history.action === 'POP') { const oldPage = location.state?.page; setPage(oldPage ? oldPage : 1); } }); return () => unbind(); // eslint-disable-next-line }, []); const { status, data, isFetching, isPreviousData, } = useQuery(['list', {page: page}], () => fetchList(page), {keepPreviousData: true}); if (status === 'loading') { return <p>Идет загрузка данных...</p> } if (status === 'error') { return <p>Что-то пошло не так...</p> } return ( <div> {data.posts.length ? ( <> <h1>Все посты блога</h1> <ul> {data.posts.map(post => ( <li key={post.uuid}> <Link to={'/post/' + post.uuid}>{post.title}</Link> </li> ))} </ul> {data.hasPrevPage && <button onClick={handlePrevClick}>Предыдущая</button>} <strong> страница {page} </strong> {data.hasNextPage && <button onClick={handleNextClick}>Следующая</button>} </> ) : ( <p>Нет постов</p> )} {isFetching && <p>Фоновое обновление данных...</p>} {isPreviousData ? <p>Это еще старые данные</p> : <p>Это уже новые данные</p>} </div> ); }
Бесконечные запросы
Для запроса бесконечных списков (например, «загрузить еще» или «бесконечная прокрутка») в React Query есть useInfiniteQuery
. Давайте изменим компонент List
, чтобы можно было подгружать дополнительные порции постов блога.
import axios from 'axios'; import { Link } from 'react-router-dom'; import { useInfiniteQuery } from 'react-query'; const fetchList = async ({pageParam = 1}) => { const response = await axios.get('http://localhost:5000/api/list?page=' + pageParam); return response.data; } export default function List() { const { status, data, isFetching, hasNextPage, fetchNextPage, isFetchingNextPage, } = useInfiniteQuery(['list'], fetchList, { getNextPageParam: (lastPage, pages) => { // возвращает номер следующей страницы для загрузки, это // число будет передано функции fetchList как pageParam return lastPage.hasNextPage ? lastPage.currentPage + 1 : null; } }); if (status === 'loading') { return <p>Идет загрузка данных...</p> } if (status === 'error') { return <p>Что-то пошло не так...</p> } return ( <div> {data.pages.length ? ( <> <h1>Все посты блога</h1> {data.pages.map((group, index) => ( <ul key={index}> {group.posts.map(post => ( <li key={post.uuid}> <Link to={'/post/' + post.uuid}>{post.title}</Link> </li> ))} </ul> ))} {hasNextPage ? ( <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}> Загрузить новую порцию </button> ) : ( <span style={{color:'#f00'}}>Больше нечего загружать</span> )} </> ) : ( <p>Нет постов</p> )} {isFetching && isFetchingNextPage && <p>Загрузка новой порции...</p>} {isFetching && !isFetchingNextPage && <p>Фоновое обновление данных...</p>} </div> ); }
Хук useInfiniteQuery
возвращает объект, многие свойства которого нам уже знакомы по useQuery
:
const { status, data, isFetching, hasNextPage, fetchNextPage, isFetchingNextPage, } = useInfiniteQuery(.....);
Свойство data.pages
представляет собой массив всех порций данных, полученных с сервера:
[ // первая порция данных, запрос http://localhost:5000/api/list?page=1 { totalPosts: 10, totalPages: 4, currentPage: 1, hasPrevPage: false, hasNextPage: true, posts: [ { uuid: "0ace52ff-1529-4028-a355-31d9f8e910dc", title: "Первый пост блога", content: "Контент первого поста блога", author: "Сергей Иванов" }, { uuid: "21a4f06c-dbbe-4a78-bc6e-3e18e7e3a094", title: "Второй пост блога", content: "Контент второго поста блога", author: "Николай Смирнов" }, { uuid: "518d28ad-73b2-4511-86dc-1b940620f8e2", title: "Третий пост блога", content: "Контент третьего поста блога", author: "Сергей Иванов" } ] }, // вторая порция данных, запрос http://localhost:5000/api/list?page=2 { totalPosts: 10, totalPages: 4, currentPage: 2, hasPrevPage: true, hasNextPage: true, posts:[ { uuid: "f89a59f3-87a1-444e-ac04-9e7164f481b5", title: "Четвертый пост блога", content: "Контент четвертого поста блога", author: "Николай Смирнов" }, { uuid: "d51b2641-8351-48a8-9de7-5c0a5e287048", title: "Пятый пост блога", content: "Контент пятого поста блога", author: "Андрей Петров" }, { uuid: "3ae56988-0148-441a-aca4-a3708ecff42b", title: "Шестой пост блога", content: "Контент шестого поста блога", author: "Сергей Иванов" } ] } // третья порция данных, запрос http://localhost:5000/api/list?page=3 { totalPosts: 10, totalPages: 4, currentPage: 3, hasPrevPage: true, hasNextPage: true, posts:[ { uuid: "d662492c-f3ea-4aa1-9d06-02d13339106e", title: "Седьмой пост блога", content: "Контент седьмого поста блога", author: "Николай Смирнов" }, { uuid: "ea895b09-552b-476f-916c-6a410f5aa9a7", title: "Восьмой пост блога", content: "Контент восьмого поста блога", author: "Сергей Иванов" }, { uuid: "4fe1eb2e-2179-4268-98cc-3cf9731ec41c", title: "Девятый пост блога", content: "Контент девятого поста блога", author: "Николай Смирнов" } ] } ]
Свойство data.pageParams
представляет собой массив номеров страниц, полученных с сервера, то есть [undefined,2,3]
. При фоновом обновлении данных функция fetchList
вызывается столько раз, сколько элементов в этом массиве.
Свойство fetchNextPage
— это функция, которая позволяет запросить с сервера следующую порцию данных. Для получения следующей порции нужно знать номер следующей страницы. Для этого в объекте настроек есть свойство getNextPageParam
, которое представляет собой функцию. Эта функция получает на вход последнюю порцию данных, полученных с сервера и массив всех порций данных, полученных с сервера. А вернуть должна номер следующей страницы, которую нужно получить с сервера. Этот номер страницы будет передан функции fetchList
как pageParam
.
Рефакторинг
Хотелось бы, чтобы страница списка была более удобной для пользователей — например, чтобы работали кнопки браузера «Вперед» и «Назад». Или, можно было сохранить страницу в закладках — сейчас сохраняется только www.host.ru
. А сколько порций данных пользователь успел загрузить — мы не знаем, можем показать только первую порцию. Нам нужно при получении каждой новой порции добавлять элемент в историю браузера и изменять адресную строку — «/», «/?loaded=2», «/?loaded=3».
import axios from 'axios'; import { Link } from 'react-router-dom'; import { useInfiniteQuery, useQueryClient } from 'react-query'; import { useEffect, useRef } from 'react'; import { useHistory, useLocation } from 'react-router'; const fetchList = async ({pageParam = 1}) => { const response = await axios.get('http://localhost:5000/api/list?page=' + pageParam); return response.data; } export default function List() { const history = useHistory(); const location = useLocation(); const queryClient = useQueryClient(); // При монтировании компонента мы не знаем, сколько порций загружать, поэтому // берем число из адресной строки браузера — «/», «/?loaded=2», «/?loaded=3» const getLoaded = () => { const match = location.search.match(/loaded=[0-9]+/); if (match && match.length) { const [, loaded] = match[0].split('='); return parseInt(loaded, 10); } return 1; }; const loaded = useRef(getLoaded()); // Вспомогательная функция, которая добавляет записи в историю браузера при // нажатии кнопка «Загрузить новую порцию» — тогда сможем обеспечить и работу // кнопок браузера «Вперед» и «Назад», потому что будем следить за этим const pushState = () => { const newLoaded = loaded.current + 1; if (newLoaded !== 1) { history.push('/?loaded=' + newLoaded, {loaded: newLoaded}); } else { history.push('/', {loaded: 1}); } } const handleLoadMore = () => { fetchNextPage(); pushState(); loaded.current++; }; const { status, data, isFetching, hasNextPage, fetchNextPage, isFetchingNextPage, } = useInfiniteQuery(['list'], fetchList, { getNextPageParam: (lastPage, pages) => { // возвращает номер следующей страницы для загрузки, это // число будет передано функции fetchList как pageParam return lastPage.hasNextPage ? lastPage.currentPage + 1 : null; } }); // Следим за изменением адреса страницы, то есть «/», «/?page=2», «/?page=3». // Если изменение адреса вызвано нажатием кнопок браузера «Вперед» и «Назад», // тогда изменяем данные запроса в кэше, добавляя-удаляя элементы массива useEffect(() => { const unbind = history.listen((location) => { if (history.action === 'POP') { // нажатие кнопки «Вперед» или «Назад» const oldLoaded = location.state?.loaded ?? 1; if (oldLoaded < loaded.current) { // удаляем последнюю загруженную порцию queryClient.setQueryData(['list'], (data) => ({ pages: data.pages.slice(0, data.pages.length - 1), pageParams: data.pageParams.slice(0, data.pageParams.length - 1), })); loaded.current = oldLoaded; } if (oldLoaded > loaded.current) { // добавляем в массив следующую порцию fetchNextPage(); loaded.current = oldLoaded; } } }); return () => unbind(); }, []); // При монтировании компонента, если в адресной строке браузера «/?loaded=3», // то первая порция будет загружена автоматически, а две другие мы должны // загрузить вручную — по очереди и если есть еще, что загружать const needLoadPage = data?.pages?.length && // первая порция уже загружена? data.pages.length < loaded.current && // нужно загружать еще порции? !isFetchingNextPage && hasNextPage; // по очереди и если есть еще useEffect(() => needLoadPage && fetchNextPage(), [needLoadPage]); if (status === 'loading') { return <p>Идет загрузка данных...</p> } if (status === 'error') { return <p>Что-то пошло не так...</p> } return ( <div> {data.pages.length ? ( <> <h1>Все посты блога</h1> {data.pages.map((group, index) => ( <ul key={index}> {group.posts.map(post => ( <li key={post.uuid}> <Link to={'/post/' + post.uuid}>{post.title}</Link> </li> ))} </ul> ))} {hasNextPage ? ( <button onClick={() => handleLoadMore()} disabled={isFetchingNextPage}> Загрузить новую порцию </button> ) : ( <span style={{color:'#f00'}}>Больше нечего загружать</span> )} </> ) : ( <p>Нет постов</p> )} {isFetching && isFetchingNextPage && <p>Загрузка новой порции...</p>} {isFetching && !isFetchingNextPage && <p>Фоновое обновление данных...</p>} </div> ); }
Мутации (mutations)
Если хук useQuery
используется для получения и кэширования данных, то хук useMutation
— для создания, обновления и удаления данных или для выполнения побочных эффектов на сервере. Давайте изменим наш блог, чтобы у списка постов были кнопки «Редактировать» и «Удалить». Для этого наш api-сервер должен уметь выполнять эти операции — с него и начнем.
import express from 'express'; import cors from 'cors'; import parser from 'body-parser'; import { v4 as uuidv4 } from 'uuid'; const posts = [ { uuid: uuidv4(), title: 'Первый пост блога', content: 'Контент первого поста блога', author: 'Сергей Иванов', }, { uuid: uuidv4(), title: 'Второй пост блога', content: 'Контент второго поста блога', author: 'Николай Смирнов', }, { uuid: uuidv4(), title: 'Третий пост блога', content: 'Контент третьего поста блога', author: 'Сергей Иванов', }, ]; 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(parser.json()); // GET-запрос на получение всего списка постов app.get('/api/list', (req, res) => { delay(2000); res.json(posts); }); // GET-запрос поста блога по идентификатору app.get('/api/post/:uuid', (req, res) => { delay(2000); const post = posts.find(item => item.uuid === req.params.uuid); if (post) { res.json(post); } else { res.status(404).send(); } }); // POST-запрос на добавление поста блога app.post('/api/append', (req, res) => { delay(2000); const uuid = uuidv4(); const { title, content, author } = req.body; const newPost = { uuid, title, content, author }; posts.push(newPost); // возвращаем в ответе новый пост res.json(newPost); }); // PUT-запрос на обновление поста блога app.put('/api/update/:uuid', (req, res) => { delay(2000); const uuid = req.params.uuid; const index = posts.findIndex(elem => elem.uuid === uuid); if (index >= 0) { const { title, content, author } = req.body; posts[index].title = title; posts[index].content = content; posts[index].author = author; const newPost = { uuid, title, content, author }; // возвращаем в ответе обновленный пост res.json(newPost); } else { res.status(404).send(); } }); // DELETE-запрос на удаление поста блога app.delete('/api/delete/:uuid', (req, res) => { delay(2000); const index = posts.findIndex(elem => elem.uuid === req.params.uuid); if (index >= 0) { const deleted = posts.splice(index, 1); // возвращаем в ответе удаленный пост res.json(deleted[0]); } else { res.status(404).send(); } }); app.listen(5000);
Теперь займемся компонентом List
:
import axios from 'axios'; import { Link } from 'react-router-dom'; import { useQuery } from 'react-query'; import { useState } from 'react'; import UpdateForm from './UpdateForm'; import AppendForm from './AppendForm'; import DeleteForm from './DeleteForm'; const fetchList = async () => { const response = await axios.get('http://localhost:5000/api/list'); return response.data; }; export default function List() { // признак того, что редактируется пост блога const [update, setUpdate] = useState(false); // признак того, что добавляется пост блога const [append, setAppend] = useState(false); const { status, data: posts, isFetching } = useQuery(['list'], fetchList); if (status === 'loading') { return <p>Идет загрузка данных...</p> } if (status === 'error') { return <p>Что-то пошло не так...</p> } return ( <div> <h1>Все посты блога</h1> {posts.length ? ( <div> {posts.map(post => ( <article key={post.uuid}> <p><Link to={'/post/' + post.uuid}>{post.title}</Link></p> {update && update === post.uuid ? ( <> <UpdateForm {...post} setUpdate={setUpdate} /> <button onClick={() => setUpdate(false)} style={{color:'#0a0'}}> Отменить </button> </> ) : ( <button onClick={() => setUpdate(post.uuid)} style={{color:'#0a0'}}> Редактировать </button> )} <DeleteForm uuid={post.uuid} /> </article> ))} </div> ) : ( <p>Нет постов</p> )} {append ? ( <> <h2>Создать новый пост</h2> <AppendForm setAppend={setAppend} /> <button onClick={() => setAppend(false)}>Отменить</button> </> ) : ( <p> <button onClick={() => setAppend(true)}>Новый пост</button> </p> )} {isFetching && <p>Фоновое обновление данных...</p>} </div> ); }
За добавление, редактирование и удаление отвечают компоненты AppendForm
, UpdateForm
и DeleteForm
.
import axios from 'axios'; import { useMutation, useQueryClient } from 'react-query'; import Form from './Form'; const appendPost = (data) => { const url = 'http://localhost:5000/api/append'; return axios.post(url, data); }; export default function AppendForm(props) { const queryClient = useQueryClient(); const mutation = useMutation(appendPost, { onSuccess: (data, vars) => { // скрыть форму добавления поста после сохранения props.setAppend(false); // список теперь устарел, инвалидируем запрос, чтобы // React Query выполнил новый запрос к API-серверу queryClient.invalidateQueries(['list']); }, }); const handleSave = (data) => { mutation.mutate(data); }; return <Form title="" content="" author="" handleSave={handleSave} loading={mutation.isLoading} /> }
import axios from 'axios'; import { useMutation, useQueryClient } from 'react-query'; import Form from './Form'; const updatePost = (data) => { const url = 'http://localhost:5000/api/update/' + data.uuid; return axios.put(url, data); }; export default function UpdateForm(props) { const { uuid, title, content, author } = props; const queryClient = useQueryClient(); const mutation = useMutation(updatePost, { onSuccess: (data, vars) => { // скрыть форму редактирования поста после сохранения props.setUpdate(false); // список теперь устарел, инвалидируем запрос, чтобы // React Query выполнил новый запрос к API-серверу queryClient.invalidateQueries(['list']); }, }); const handleSave = (data) => { data.uuid = uuid; mutation.mutate(data); }; return <Form title={title} content={content} author={author} handleSave={handleSave} loading={mutation.isLoading} /> }
import axios from 'axios'; import { useMutation, useQueryClient } from 'react-query'; const deletePost = (uuid) => { const url = 'http://localhost:5000/api/delete/' + uuid; return axios.delete(url); }; export default function DeleteForm(props) { const queryClient = useQueryClient(); const mutation = useMutation(deletePost, { onSuccess: (data, vars) => { // список теперь устарел, инвалидируем запрос, чтобы // React Query выполнил новый запрос к API-серверу queryClient.invalidateQueries(['list']); }, }); const handleSubmit = (event) => { event.preventDefault(); mutation.mutate(props.uuid); }; return ( <form onSubmit={handleSubmit} style={{display:'inline-block'}}> <input type="submit" value="Удалить" style={{color:'#f00'}} /> {mutation.isLoading && <span style={{color:'#f00'}}>Удаление...</span>} </form> ) }
Вспомогательный компонент Form
показывает форму создания-редактирования поста:
import { useState, useEffect } from 'react'; export default function Form(props) { const [title, setTitle] = useState(props.title); const [content, setContent] = useState(props.content); const [author, setAuthor] = useState(props.author); const handleChange = (event) => { if (event.target.name === 'title') { setTitle(event.target.value); } if (event.target.name === 'content') { setContent(event.target.value); } if (event.target.name === 'author') { setAuthor(event.target.value); } }; const handleSubmit = (event) => { event.preventDefault(); if (title.trim() === '') return; if (content.trim() === '') return; if (author.trim() === '') return; props.handleSave({title, content, author}); } const formStyle = { backgroundColor: '#eee', padding: 5, marginBottom: 5, } return ( <form onSubmit={handleSubmit} style={formStyle}> <input type="text" name="title" value={title} onChange={handleChange} placeholder="Заголовок" /> <input type="text" name="content" value={content} onChange={handleChange} placeholder="Контент" /> <input type="text" name="author" value={author} onChange={handleChange} placeholder="Автор" /> <input type="submit" value="Сохранить" /> {props.loading && <span style={{color:'#0a0'}}>Сохранение...</span>} </form> ); }
Для наглядности, в компоненте App
выставлены следующие настройки запросов:
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 10 * 60 * 1000, // 10 минут cacheTime: 60 * 60 * 1000, // 60 минут }, }, });
Самое интересное происходит в этих трех фрагментах кода:
const mutation = useMutation(appendPost, { onSuccess: (data, vars) => { // скрыть форму добавления поста после сохранения props.setAppend(false); // список теперь устарел, инвалидируем запрос, чтобы // React Query выполнил новый запрос к API-серверу queryClient.invalidateQueries(['list']); }, });
const mutation = useMutation(updatePost, { onSuccess: (data, vars) => { // скрыть форму редактирования поста после сохранения props.setUpdate(false); // список теперь устарел, инвалидируем запрос, чтобы // React Query выполнил новый запрос к API-серверу queryClient.invalidateQueries(['list']); }, });
const mutation = useMutation(deletePost, { onSuccess: (data, vars) => { // список теперь устарел, инвалидируем запрос, чтобы // React Query выполнил новый запрос к API-серверу queryClient.invalidateQueries(['list']); }, });
Если закомментировать строку кода инвалидации запроса — то после добавления, обновления или удаления поста блога — мы не увидим никаких изменений в списке постов. Нам надо либо инвалидировать кэш списка постов, либо самостоятельно выполнить запрос к api-серверу с помощью queryClient.refetchQueries()
.
Инвалидация запросов
Метод invalidateQueries
клиента запроса позволяет помечать запросы как устаревшие и потенциально выполнять повторное получение данных:
// Инвалидация всех запросов, находящихся в кэше queryClient.invalidateQueries(); // Инвалидация запроса списка постов блога queryClient.invalidateQueries(['list']);
При инвалидации запроса с помощью invalidateQueries
происходит две вещи:
- Запрос помечается как устаревший. При этом, настройка
staleTime
вuseQuery
и других хуках перезаписывается - Если запрос отрендерен с помощью
useQuery
или других хуков, он выполняется повторно в фоновом режиме
Дополнительно
Исходные коды примеров здесь, полезное видео на YouTube здесь.
Поиск: API • JavaScript • React.js • Web-разработка • Frontend • Блог • Запрос • Модуль • Сервер