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

24.09.2021

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

Традиционный метод fetch() отлично подходит для извлечения данных с помощью API. Однако по мере разрастания и усложнения приложения можно столкнуться с рядом трудностей. Первая трудность — кэширование полученных данных и поддержание кэша в актуальном состоянии. Вторая трудность — большой объем данных, получаемых от сервера — может потребоваться пагинация.

И вот тут в дело вступает пакет React Query. Эта библиотека берет заботы о кэшировании на себя, вследствие чего пропадает необходимость работать с заголовками кэширования и браузерным кэшированием, а также упрощается процесс пагинации. React Query облегчает жизнь, устраняя многие проблемы, связанные с получением данных и управлением состоянием сервера.

Простой блог без React Query

Давайте напишем небольшое приложение — сначала без использования 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: 'Андрей Петров',
    }
];

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(3000);
    res.json(posts);
});

// GET-запрос поста блога по идентификатору
app.get('/api/post/:uuid', (req, res) => {
    delay(3000);
    const post = posts.find(item => item.uuid === req.params.uuid);
    if (post) {
        res.json(post);
    } else {
        res.status(404).send();
    }
});

app.listen(5000);

Сервер возвращает список постов блога и отдельный пост по идентификатору. Функция delay() добавлена намеренно, чтобы можно было увидеть разницу, когда начнем использовать React Query. А пока продолжим создание приложения.

import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import List from './List';
import Post from './Post';
import NotFound from './NotFound';

export default function App() {
    return (
        <Router>
            <Switch>
                <Route exact path="/" component={List} />
                <Route path="/post/:uuid" component={Post} />
                <Route component={NotFound} />
            </Switch>
        </Router>
    );
}

Компонент List отвечает за показ списка постов, а компонент Post — за показ отдельного поста блога.

import { useState, useEffect } from 'react';
import axios from 'axios';
import { Link } from 'react-router-dom';

export default function List() {
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(false);
    const [posts, setPosts] = useState([]);

    useEffect(() => {
        const fetchList = async () => {
            try {
                const response = await axios.get('http://localhost:5000/api/list');
                setPosts(response.data);
            } catch (error) {
                setError(true);
            }
            setLoading(false);
        };
        fetchList();
    }, []);

    return (
        <div>
            <h1>Все посты блога</h1>
            {error ? (
                <p>Что-то пошло не так...</p>
            ) : loading ? (
                <p>Идет загрузка данных...</p>
            ) : posts.length ? (
                <ul>
                    {posts.map(post => (
                        <li key={post.uuid}>
                            <Link to={'/post/' + post.uuid}>{post.title}</Link>
                        </li>
                    ))}
                </ul>
            ) : (
                <p>Нет постов</p>
            )}
        </div>
    );
}
import { useState, useEffect } from 'react';
import axios from 'axios';
import { useParams, useHistory } from 'react-router';

export default function Post() {
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(false);
    const [post, setPost] = useState(null);

    const history = useHistory();
    const params = useParams();

    useEffect(() => {
        const fetchPost = async () => {
            try {
                const url = 'http://localhost:5000/api/post/' + params.uuid;
                const response = await axios.get(url);
                setPost(response.data);
            } catch (err) {
                if (err.response?.status !== 404) {
                    setError(true);
                }
            }
            setLoading(false);
        };
        fetchPost();
    }, []);

    return (
        <div>
            {error ? (
                <p>Что-то пошло не так...</p>
            ) : loading ? (
                <p>Идет загрузка данных...</p>
            ) : post ? (
                <>
                    <h1>{post.title}</h1>
                    <p>{post.content}</p>
                    <p>Автор: {post.author}</p>
                    <button onClick={() => history.goBack()}>Вернуться назад</button>
                </>
            ) : (
                <p>Пост блога не найден</p>
            )}
        </div>
    );
}
export default function NotFound() {
    return <h1>Page not found</h1>
}

Запускаем сервер

> node src/api/index.mjs

Запускаем приложение

> npm start

Простой блог с React Query

Первым делом нужно изменить компонент App — обернуть его в QueryClientProvider, чтобы потом в потомках App получать доступ к данным запросов с помощью хука useQuery. Заодно добавляем ReactQueryDevtool — чтобы иметь возможность просмотра кэшированных данных.

import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import List from './List';
import Post from './Post';
import NotFound from './NotFound';

import {
    QueryClient,
    QueryClientProvider
} from 'react-query';

import { ReactQueryDevtools } from 'react-query/devtools';

const queryClient = new QueryClient();

export default function App() {
    return (
        <QueryClientProvider client={queryClient}>
            <Router>
                <Switch>
                    <Route exact path="/" component={List} />
                    <Route path="/post/:uuid" component={Post} />
                    <Route component={NotFound} />
                </Switch>
            </Router>
            <ReactQueryDevtools initialIsOpen={true} />
        </QueryClientProvider>
    );
}

Теперь изменяем компоненты List и Post — больше не нужно использовать хук useState для хранения данных.

import axios from 'axios';
import { Link } from 'react-router-dom';
import { useQuery } from 'react-query';

const fetchList = async () => {
    const response = await axios.get('http://localhost:5000/api/list');
    return response.data;
}

export default function List() {
    const { status, data: posts, isFetching, error } = useQuery(['list'], fetchList);

    if (status === 'loading') {
        return <p>Идет загрузка данных...</p>
    }
    
    if (status === 'error') {
        return <p>Что-то пошло не так...</p>
    }

    return (
        <div>
            <h1>Все посты блога</h1>
            {posts.length ? (
                <ul>
                    {posts.map(post => (
                        <li key={post.uuid}>
                            <Link to={'/post/' + post.uuid}>{post.title}</Link>
                        </li>
                    ))}
                </ul>
            ) : (
                <p>Нет постов</p>
            )}
            {isFetching && <p>Обновление данных...</p>}
        </div>
    );
}
import axios from 'axios';
import { useParams, useHistory } from 'react-router';

import { useQuery } from 'react-query';

const fetchPost = async (uuid) => {
    const url = 'http://localhost:5000/api/post/' + uuid;
    const response = await axios.get(url);
    return response.data;
}

export default function Post() {
    const history = useHistory();
    const params = useParams();

    const {
        status,
        data: post,
        isFetching,
        error
    } = useQuery(['post', params.uuid], () => fetchPost(params.uuid));

    if (status === 'loading') {
        return <p>Идет загрузка данных...</p>
    }
    
    if (status === 'error') {
        return <p>Что-то пошло не так...</p>
    }

    return (
        <div>
            {post ? (
                <>
                    <h1>{post.title}</h1>
                    <p>{post.content}</p>
                    <p>Автор: {post.author}</p>
                    <button onClick={() => history.goBack()}>Вернуться назад</button>
                </>
            ) : (
                <p>Пост блога не найден</p>
            )}
            {isFetching && <p>Обновление данных...</p>}
        </div>
    );
}

Запускаем сервер

> node src/api/index.mjs

Запускаем приложение

> npm start

И сразу видим, что все стало гораздо веселее — текст «Идет загрузка данных...» появляется только один раз при просмотре списка или отдельного поста. При повторном просмотре список и пост появляются практически мгновенно — потому что React Query возвращает их из кэша. Но зато внизу теперь появляется текст «Идет загрузка данных...» — это выполняется фоновый запрос на актуализацию кэша. Это потому, что кэшированные данные по-умолчанию считаются устаревшими (stale) — это можно видеть в панели DevTools.

Устаревшие запросы автоматически повторно выполняются в фоновом режиме (background) в следующих случаях:

  • Монтирование нового экземпляра запроса — то есть вызов useQuery или useInfiniteQuery
  • Переключения окна/вкладки в браузере или повторное подключение к сети после потери соединения
  • Когда задан интервал выполнения повторного запроса (refetch interval) — об этом поговорим чуть позже

Результаты запроса, не имеющие активных экземпляров useQuery или useInfiniteQuery помечаются как неактивные (inactive) и уничтожаются сборщиком мусора через 5 минут. Провалившиеся запросы автоматически повторно выполняются 3 раза с увеличивающейся задержкой перед передачей ошибки в вызывающий код.

Настройки запроса

Хук useQuery принимает три параметра — ключ запроса, функция запроса и объект настроек. Настроек этих огромное количество, так что рассмотрим только некоторые — остальные смотрите в документации.

const settings = { /* ... */ };
useQuery(queryKey, queryFn, settings);

1. Устаревание данных

Если у нас обычный блог, который обновляется два-три раза в неделю — нет необходимости считать данные устаревшими (stale) сразу после их получения. Так что можем задать значение staleTime достаточно большим, чтобы лишний раз не дергать сервер. Можно задать настройку глобально или для конкретного запроса.

const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            staleTime: 60 * 60 * 1000, // 60 минут
            cacheTime: 1000 * 60 * 60 * 24 // 24 часа
        },
    },
});

function App() {
    return (
        <QueryClientProvider client={queryClient}>
        ...
        </QueryClientProvider>
    );
}
// настройка для конкртетного запроса (список постов)
const {
    status,
    data: posts,
    isFetching,
    error
} = useQuery(['list'], fetchList, {staleTime: 60000}); // одна минута
// настройка для конкртетного запроса (отдельный пост)
const {
    status,
    data: post,
    isFetching,
    error
} = useQuery(['post', params.uuid], () => fetchPost(params.uuid), {staleTime: 60000}); // одна минута

2. Время жизни кэша

Это время в мс, в течение которого неиспользуемый/неактивный кэш сохраняется в памяти, после чего такой кэш уничтожается сборщиком мусора (по умолчанию через 5 минут). Можно задать настройку глобально или для конкретного запроса.

// на глобальном уровне
const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            cacheTime: 1000 * 60 * 60 * 24 // 24 часа
        }
    }
});

function App() {
    return (
        <QueryClientProvider client={queryClient}>
        ...
        </QueryClientProvider>
    );
}
// для отдельного запроса
const {
    status,
    data: posts,
    isFetching
} = useQuery(['list'], fetchList, {cacheTime: 1000 * 60 * 60}); // 60 минут

3. Переключение вкладки

Если пользователь переключается на другое окно/вкладку браузера, а потом возвращается обратно, React Query автоматически запрашивает свежие данные в фоновом режиме. Это поведение можно отключить глобально или в отношении конкретного запроса.

// глобальное отключение
const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            refetchOnWindowFocus: false,
        },
    },
});
// отключение для запроса
const {
    status,
    data: posts,
    isFetching
} = useQuery(['list'], fetchList, {refetchOnWindowFocus: false});
// отключение для запроса
const {
    status,
    data: post,
    isFetching
} = useQuery(['post', params.uuid], () => fetchPost(params.uuid), {refetchOnWindowFocus: false});

4. Автовыполнение запросов

Автоматическое выполнение запросов можно вообще отключить с помощью настройки enabled — в этом случае запрос нужно выполнить вручную.

import axios from 'axios';
import { Link } from 'react-router-dom';
import { useQuery } from 'react-query';

const fetchList = async () => {
    const response = await axios.get('http://localhost:5000/api/list');
    return response.data;
}

export default function List() {
    const { status, data: posts, refetch } = useQuery(['list'], fetchList, {enabled: false});

    if (status === 'loading') {
        return <p>Идет загрузка данных...</p>
    }

    if (status === 'error') {
        return <p>Что-то пошло не так...</p>
    }

    // посты будут загружены при клике на кнопку
    if (!posts) {
        return <button onClick={() => refetch()}>Загрузить посты</button>
    }

    return (
        <div>
            <h1>Все посты блога</h1>
            {posts.length ? (
                <ul>
                    {posts.map(post => (
                        <li key={post.uuid}>
                            <Link to={'/post/' + post.uuid}>{post.title}</Link>
                        </li>
                    ))}
                </ul>
            ) : (
                <p>Нет постов</p>
            )}
        </div>
    );
}

5. Провалившиеся запросы

При провале запроса, автоматически выполняется 3 попытки его повторного выполнения. Это поведение можно изменить как на глобальном уровне, так и на уровне конкретного запроса. Установка значения false отключает повторы, установка значения true делает повторы бесконечными.

// на глобальном уровне
const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            retry: 10,
        },
    },
});
// для отдельного запроса
const {
    status,
    data: post,
    isFetching
} = useQuery(['post', params.uuid], () => fetchPost(params.uuid), {retry: 10});

Настройки staleTime и cacheTime

Очень трудно понять, чем они отличаются. Поэтому на странице документации есть объяснение. Предположим, значение cacheTime и staleTime имеют значения по умолчанию, то есть 5 минут и ноль.

Монтируется первый экземпляр useQuery('list', fetchList). Поскольку данных в кэше еще нет, будет выполнен запрос на их получение. Данные будут кэшированы, используя list и fetchList в качестве идентификаторов кеша. И сразу будут помечены как устаревшие, поскольку staleTime равно нулю.

Монтируется второй экземпляр useQuery('list', fetchList) в другом месте. Поскольку данные есть в кэше, они немедленно возвращаются. Фоновая выборка запускается для обоих запросов, поскольку на экране появился новый экземпляр. Оба экземпляра обновляются новыми данными, если выборка прошла успешно.

Оба экземпляра запроса useQuery('list', fetchList) отключены и больше не используются. Поскольку активных экземпляров этого запроса больше нет, данные из каша будут удалены через пять минут (1000*60*5 мс), если не будет смонтирован еще один экземпляр.

До того, как истекло время cacheTime, монтируется еще один экземпляр useQuery('list', fetchList). Запрос немедленно возвращает данные из кэша, пока функция fetchList выполняется в фоновом режиме, чтобы заполнить запрос новым значением.

Последний экземпляр useQuery('list', fetchList) размонтирован. Больше никаких экземпляров useQuery('list', fetchList) не появляется в течение 5 минут. Этот запрос и его данные удаляются и собираются сборщиком мусора.

Дополнительно

Исходные коды примеров здесь, полезное видео на 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.