Декларативное отображение состояния react-query через switch-query
switch-query — простой npm-пакет, который упрощает отображение состояния из @tanstach/react-query. Экспортируется один компонент SwitchQuery и его типы. Несмотря на простоту, пакет упрощает структуру кода и реализацию более продуманного дизайна.
Состояния запросов
В общем случае, для отображения пользователю наиболее достоверной информации, получаемой с сервера, необходимо учесть следующие сценарии:
- Успешный — запрос выполнен успешно, отображаются данные.
- Неудачный — запрос завершился с ошибкой.
- Загрузка — запрос выполняется, данные ещё не доступны.
- Пустое состояние — ответ успешный, но данных нет.
В @tanstack/react-query реализован type narrowing. Это позволяет явно разделить состояния запроса с помощью строгой типизации.
Возьмём пример с официального сайта:
import { useQuery } from '@tanstack/react-query';
function Todos() {
const { data, isPending, error } = useQuery({
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then((r) => r.json()),
});
if (isPending) {
return <span>Loading...</span>;
}
if (error) {
return <span>Oops!</span>;
}
return (
<ul>
{data.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
);
}
export default Todos;
По моему мнению, это одно из основных преимуществ библиотеки: она подталкивает разработчиков к реализации более продуманного интерфейса.
Однако при использовании if и return становится неудобно добавлять общие для каждого состояния элементы. Например, добавим контейнер и кнопку создания.
Пример модифицированного кода
import { useQuery } from '@tanstack/react-query';
function Todos() {
const { data, isPending, error } = useQuery({
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then((r) => r.json()),
});
if (isPending) {
return (
<div className="container">
<span>Loading...</span>
<button>add</button>
</div>
);
}
if (error) {
return (
<div className="container">
<span>Oops!</span>
<button>add</button>
</div>
);
}
return (
<div className="container">
<ul>
{data.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
<button>add</button>
</div>
);
}
export default Todos;
Вариант с условиями внутри JSX, который решает проблему дублирования кода, но является менее явным и оставляет возможность допустить ошибку и отобразить несколько состояний одновременно:
import { useQuery } from '@tanstack/react-query';
function Todos() {
const { data, isPending, error, isSuccess } = useQuery({
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then((r) => r.json()),
});
return (
<div className="container">
{isPending && <span>Loading...</span>}
{error && <span>Oops!</span>}
{isSuccess && (
<ul>
{data.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
)}
<button>add</button>
</div>
);
}
export default Todos;
С помощью switch-query такая задача решается легко благодаря инкапсуляции проверок во вложенном JSX-элементе:
import { useQuery } from '@tanstack/react-query';
import { SwitchQuery } from 'switch-query';
function Todos() {
const query = useQuery({
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then((r) => r.json()),
});
return (
<div className="container">
<SwitchQuery
query={query}
pending={<span>Loading...</span>}
error={<span>Oops!</span>}
success={({ data }) => (
<ul>
{data.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
)}
/>
<button>add</button>
</div>
);
}
export default Todos;
Такой подход удобнее в контексте JSX. Не нужно избыточно декомпозировать компоненты или использовать вложенные тернарные операторы.
Несколько запросов в одном компоненте
Рендер через JSX позволяет работать с несколькими запросами в одном компоненте.
Предположим, что каждый элемент ссылается на справочник тегов через tagId и необходимо вывести заголовок тега.
В таком случае можно вложить <SwitchQuery /> друг в друга. При этом не важна последовательность получения ответов, так как все состояния учитываются на всех уровнях.
import { useQuery } from '@tanstack/react-query';
import { SwitchQuery } from 'switch-query';
function Todos() {
const todos = useQuery({
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then((r) => r.json()),
});
const tags = useQuery({
queryKey: ['tags'],
queryFn: () => fetch('/api/tags').then((r) => r.json()),
});
return (
<div className="container">
<SwitchQuery
query={todos}
pending={<span>Loading...</span>}
error={<span>Oops!</span>}
success={({ data }) => (
<ul>
{data.map((t) => (
<li key={t.id}>
{t.title}
<SwitchQuery
query={tags}
success={({ data }) => data[t.tagId].title}
pending="loading..."
error={`#${t.tagId}`}
/>
</li>
))}
</ul>
)}
/>
<button>add</button>
<div>
<h2>Tags:</h2>
<SwitchQuery
query={tags}
success={({ data }) => (
<ul>
{Object.keys(data).map((id) => (
<li key={id}>{data[id].title}</li>
))}
</ul>
)}
pending="loading..."
error={({ error, refetch }) => (
<>
<div>{error.message}</div>
<button onClick={() => refetch()}>retry</button>
</>
)}
/>
</div>
</div>
);
}
export default Todos;
Реализация подобного отображения без switch-query потребовала бы создания дополнительных компонентов.
Пустые состояния
Для хорошего дизайна нужно явно информировать пользователя об отсутствии данных.
Обработка пустого состояния в switch-query вынесена в дополнительные свойства:
<SwitchQuery
query={query}
success={({ data }) => (
<ul>
{data.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
)}
checkIsEmpty={(data) => !data.length}
empty={<div>Nothing to do</div>}
/>
Обработка всех возможных состояний приводится к более декларативному стилю.
Заключение
Можно предположить, что все приведённые примеры выше некорректны, и было бы правильнее вынести все в отдельные компоненты. Это вполне корректная позиция. Можно даже использовать useSuspenseQuery. Однако в таком случае реализация отдельного блока интерфейса разносится по разным компонентам и файлам, которым ещё нужно придумать названия.
Я считаю, что @tanstack/react-query размывает устоявшиеся паттерны, делая запрос неотъемлемой частью отображения, что соответствует конечному результату. Простое декларативное API библиотеки отлично дополняется декларативной натурой JSX.