Декларативное отображение состояния 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.

Star on GitHub