DEV Community

Cover image for Declarative rendering of react-query state via switch-query
Mikhail Panichev
Mikhail Panichev

Posted on

Declarative rendering of react-query state via switch-query

Russian version

switch-query is a tiny npm package that simplifies displaying the state from @tanstack/react-query. It exports a single component, SwitchQuery, along with its types. Despite its simplicity, the package streamlines code structure and enables more thoughtful design UI/UX.

Request states

In general, to provide users with the most accurate information retrieved from the server, the following scenarios must be considered:

  • Success — the request was successful, and data is displayed.
  • Failure — the request resulted in an error.
  • Loading — the request is in progress, and data is not yet available.
  • Empty state — the response was successful, but there is no data.

@tanstack/react-query implements types narrowing. This allows you to explicitly separate request states using strict typing.

Let’s take an example from the official website:

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;
Enter fullscreen mode Exit fullscreen mode

In my opinion, this is one of the library’s key advantages: it encourages developers to create more thoughtful user interfaces.

However, when using if and return statements, it becomes inconvenient to add elements common to each state. For example, let’s add a container and a create button.

Example of modified code
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;
Enter fullscreen mode Exit fullscreen mode

An alternative using conditions within JSX solves the code duplication issue but is less explicit and leaves room for errors, such as displaying multiple states simultaneously:

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;
Enter fullscreen mode Exit fullscreen mode

With switch-query, this task is easily solved by encapsulating checks within a nested JSX element:

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;
Enter fullscreen mode Exit fullscreen mode

This approach is more convenient in the JSX context. There’s no need to excessively decompose components or use nested ternary operators.

Multiple queries in a single component

Rendering via JSX allows working with multiple queries in a single component.

Suppose each item references a tag dictionary via tagId, and we need to display the tag title.

In this case, you can nest <SwitchQuery /> elements within each other. The order in which responses are received doesn’t matter, as all states are accounted for at every level.

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.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.title}</li>
                            ))}
                        </ul>
                    )}
                    pending="loading..."
                    error={({ error, refetch }) => (
                        <>
                            <div>{error.message}</div>
                            <button onClick={() => refetch()}>retry</button>
                        </>
                    )}
                />
            </div>
        </div>
    );
}

export default Todos;
Enter fullscreen mode Exit fullscreen mode

Implementing such a view without switch-query would require creating additional components.

Empty states

For good design, it’s also important to explicitly inform the user when there is no data.

In switch-query empty state case is moved to additional props:

<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>}
/>
Enter fullscreen mode Exit fullscreen mode

Handling all possible states is brought to a more declarative style.

Conclusion

One might argue that all the examples above are flawed and it would be better to move everything into separate components. This is a perfectly valid stance and you could even use useSuspenseQuery.

However, in this case, implementation of a single interface block would be spread across multiple components and files, which would also need to be named.

I believe that @tanstack/react-query blurs established patterns by making the request an integral part of the views, which aligns with the final result. The library’s declarative API is perfectly complemented by the declarative nature of JSX.

Star on GitHub

Top comments (0)