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;
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.
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: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;
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;
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;
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;
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>}
/>
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.
Top comments (0)