React.js. Модуль React Query. Часть 2 из 2

03.10.2021

Теги: APIFrontendJavaScriptReact.jsWeb-разработкаБлогЗапросМодульСервер

Запросы для пагинации

Для реализации пагинации с помощью 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 • Блог • Запрос • Модуль • Сервер

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.