Chapter 2: Modern Frontend Development with React
This is Part 3 of the Modern Frontend Development series. There used to be one place called 'state' where everything went: API responses, modals, form drafts, theme, all sharing the same store. Once server state and client state emerged as separate concepts, the tools and patterns on each side became much clearer.
You'll see how server state and client state get handled in real-world scenarios, alongside popular libraries like TanStack Query and Zustand. The point is to help you pick the right library for each case and understand why, based on the actual characteristics of server vs client state, not on habit or tech hype.
Prerequisites
Server vs Client State
Tutorial Content
6 sections • about 75 minutes
Rewind a few years. If you asked a React developer where state lived, the answer fit on one line: in a useState here, lifted to a parent there, and for anything bigger, a store. Redux, MobX, flavor of the month, whatever you reached for, the job was the same: hold the bits the UI needs, let components subscribe.
Back then I was a huge Redux fan. Learning it in my early React days was tough: the dispatch-action-reducer flow, the immutability rules, the HOC boilerplate. When it finally clicked, though, it came with something that felt like magic. Open the DevTools, time-travel through the state, replay every action that had ever fired. If the store held everything, the DevTools saw everything. That kind of visibility is hard to walk away from.
At peak Redux hype, that pattern got pushed all the way. redux-form held your form state. Thunks dumped API responses into slices right next to it. Theme, modals, auth session, cached lists from the server, every bit of it got its own reducer. The store became the app, and whether a piece of state came from a remote API or a local click, it all lived under the same word: state.
one store, everything inside
Then the API work piled up. Every new endpoint meant the same routine: action type, action creator, FETCH_REQUEST / SUCCESS / FAILURE cases, a selector, a thunk or saga. Each one shipped the same loading / error / data triple a dozen others already had. Reaching for redux-saga to tame async only added more: generators, takeLatest, put, call, cancellation patterns. A whole second language, and two new endpoints still meant two new sagas plus slices that looked identical to the ones I wrote last sprint.
Caching was the part that never stopped hurting. Navigate away from the feed and back, and the slice refetched from scratch. Two components mount together, both dispatch the same fetch. Mutate an item on one screen, and a third reducer case is what kept the other screen in sync. Each of those was a problem I solved by hand, badly, in a different shape each time.
And because remote data and local flags shared one store, they bled into each other. A keystroke in redux-form could re-render a list backed by API data across the app. Without reselect and memoization everywhere, it stuttered.
At some point I started wondering what the app would look like if all that server-side pain lived somewhere else entirely. Imagine a separate layer that owned the cache, the deduping, the background refetch, the stale-then-fresh behavior, and the invalidation. With that handled, what's left in my store is the easy stuff: a theme, a modal flag, a sidebar toggle. Local, synchronous, small.
imagined: two sides of state
That's the split the community eventually drew, and the rest of this chapter lives on both sides of it. The next section names the two halves. The one with all the mess I just described is where we start digging in.
Pick any value in your app that came from the network. A post's like count. A user's role. Today's weather. The price on a pricing page. All of it is server state: data your app reads but doesn't own.
The definition sounds simple, but accepting it has consequences. The copy in your app isn't the source of truth. It's a snapshot, and the snapshot is stale the instant you receive it. Someone else might be writing to that same row right now. Your app didn't create this data, can't guarantee it'll still be there next second, and can't keep it in sync without going back to the server.
Each property is a different class of problem. All five apply at once, every time. That's why no tool designed for local variables handles server state well, and why hand-rolling it per endpoint was the wrong level of abstraction.
The right level is a layer that sits between your UI and the remote source. Every component that needs the same data reads from the same cache entry, and the layer decides when to reach the network. One fetch, many readers, one place where freshness and deduping live.
A layer between UI and remote
Components talk to the layer. The layer talks to the network. What the layer does inside (cache, dedupe, revalidate) stays inside.
Once the layer exists, it runs a full cycle every time data flows in or out. Each stage is a responsibility your components used to own; now it lives in one place.
The network call to the remote. The layer fires it when needed (cold read, revalidation, after invalidation) and dedupes concurrent calls for the same key.
Store each response under its key. The key is identity: same key means same entry, shared by every component that asks for it.
Subscribers read the cached value instantly. No spinner, no loading boolean scattered across components.
After the freshness window, the next read triggers a background fetch. The old value renders while the new one loads.
Writes run against the remote. The cache payload catches up through invalidation, not direct writes.
Mark related keys as untrusted. On the next read the layer refetches, surgically updates, or drops the entry. One signal, every subscriber refreshed.
That's the server-state role: your components declare what data they want and what mutations to run, and the layer runs the full cycle on their behalf. Everything beyond this (optimistic updates, realtime, offline sync, garbage collection when no one's subscribed) is a variation on these six moves.
A fair question: can you skip the server-state layer and handle all this with Redux, Context, or hand-rolled useState? Yes. I've done it on plenty of projects. You end up rebuilding the same primitives the library ships with, just worse.
Here's what a hand-rolled version has to own, for every endpoint:
Cache and freshness per endpoint
A keyed map holding results, plus timestamps, plus "is this stale" logic. Rewritten per resource, with slightly different rules each time.
Dedupe concurrent calls
Two components mount in the same render, both dispatch the same fetch. Without a ref-tracked in-flight map, you pay the network cost twice.
Background refresh and retry
Focus listeners, reconnect listeners, polling intervals, exponential backoff on failure. Each one is a useEffect you wire by hand.
Invalidation across screens
A mutation on screen A needs screen B to refetch. Redux actions, event emitters, or a prop threaded four levels deep. Pick your poison.
Optimistic updates with rollback
Snapshot, apply optimistically, roll back on error, reconcile on success. Bespoke logic per mutation, and the edge cases bite.
Garbage collection
Tracking which entries have no subscribers, and when it's safe to evict. Skip it and the cache grows unbounded over a long session.
Six concerns, per endpoint. Ship five endpoints and you've written this five times, each slightly wrong in a different way. Six months in, the code that manages remote data is the biggest drag on the codebase, not because any single endpoint is complex but because there are so many endpoints, each with slightly different bugs.
Once you see the full list, it's clear why TanStack Query and SWR dominate React state management. They aren't popular by accident. They answer every one of those concerns in one package, tested and shared by the ecosystem. The next section will show you how Server State in React works in practice.
Two cases come up constantly with server state, and they're where TanStack Query saves the most code. The first: one piece of data feeding multiple screens at once. The second: a write that has to feel instant, well before the server confirms it. A team management dashboard is the backdrop for both, and every example below reuses the same ['users'] cache.
One QueryClient lives at the top of your app and owns every cache entry. Everything else is a hook.
The two defaults worth thinking about on day one are staleTime (how long a cached value is trusted without a re-fetch) and gcTime (how long the entry stays in memory after no component is using it). Almost every "why is this refetching on every click?" and "why did my list disappear after the modal closed?" bug traces back to these two.
The full set of useQuery defaults (refetch on focus, on reconnect, retry behavior, structural sharing, and more) is worth a read once before you start tuning anything: Important Defaults.
Walk through what an admin actually does:
/admin/users. The full filterable table.The drawer doesn't fetch anything. The row already sits in the list cache, so the preview renders the moment the drawer opens.
If they want the full profile (audit history, permissions, billing), a "View full details" button routes them to /admin/users/[id], which calls a richer endpoint dedicated to one user.
Three flows reading the same list, and one detail route on the side.
three screens · one cache
That's howe we can use TanStack Query to handle server state: one fetch on the first navigation, two cache hits after, the same ['users'] entry feeding every screen that only needs row-level fields. Without it, each screen wires up its own useEffect, every navigation re-mounts state and re-fires /api/users, and the user sees three loading spinners for a list that hasn't changed.
The library collapses that into one mechanic: every read declares a queryKey, and the key is the identity of the cached entry. Two components asking for the same key share one entry and one in-flight request.
Two components calling useUsers({ role: 'admin' }) get the same entry. Change the filter to { role: 'member' } and it's a new entry; switch back and the old data is still there, instant.
The dashboard widget and the /admin/users table both call useUsers directly. The drawer doesn't need a new query at all. It subscribes to the same cache entry and uses select to narrow the result down to one row:
Same queryKey, same cache entry, same in-flight request if one's already running. select runs after the cache read and reshapes what the component sees, so the drawer gets just its user without storing a separate copy. When the list refetches in the background, the drawer's preview updates with it.
The detail route is the different case. It hits an endpoint the list never touches, with fields the list never carries, and it deserves its own slot in the cache. For a single user, give their row its own key:
Now /admin/users/[id] reads from ['users', id], and any other surface that wants the full record reads from the same entry. One user, one cache slot, no matter how many screens care about them.
A dropdown next to each row lets admins set the role. If we wait for the server before the label updates, the UI feels like it's fighting back. This is the mutation pattern most hand-rolled solutions get wrong:
optimistic mutation · sequence
The four numbered steps are the pattern. Skip cancelQueries and a background refetch from Step 1 can overwrite your optimistic state mid-mutation. Skip onSettled and the client drifts from server truth. Every optimistic-update bug I've debugged was one of those two.
Notice setQueriesData (plural): the same user may live in the { role: 'admin' } filter entry and in the unfiltered entry at the same time. The plural version patches every matching cache at once, and the rollback snapshot knows to restore all of them.
In realworld scenarios, there are many more things to handle: pagination, infinite scroll, realtime updates, offline sync,... Tanstack Query has a really good example doc section that covers patterns for many of those (TanStack Query - examples). The important part is the model, not the patterns. Once you understand the cycle and the cache key identity, you can reason about any pattern the library ships, or even patterns your team invents.
Most TanStack Query code I write now starts as a Cursor or Claude completion. That's fine for the boilerplate; useQuery shapes look the same on every endpoint. The trouble shows up in the gaps: a cache key that looks right but isn't, a mutation racing its own invalidation, optimistic state that flickers because the snapshot was taken at the wrong moment. The model writes plausible code. You're still the one reviewing it, debugging it on call, and explaining it in a PR.
These three posts from TkDodo (one of the TanStack Query maintainers) are what I go back to whenever I need to push past plausible:
Read those once and the next AI-generated useQuery block isn't something you have to trust. It's something you can actually evaluate, review, and own when it breaks.
That's server state covered. What's left in state is everything the network never touches: theme, sidebar toggles, modals, a half-typed query in a search box. Section 4 picks that up.
None of the server-state cycle (the cache, the dedupe, the invalidation, the optimistic updates,...) applies to a sidebar toggle. What's left isn't a smaller copy of the same problem. It's a different problem, and a much friendlier one.
Walk through what an app actually holds outside of the network:
Every one of those values has the same shape. The browser put them there in response to a click or a keystroke. Your app is the only writer. They change synchronously, and if the tab closes, most of them can vanish without a single user noticing.
Three properties define it, and the rest follows.
Your app put this value here. No remote authority can overwrite you while the user is typing. No one else is racing you for it.
Reading or writing it never touches the network. No loading state, no error state, no stale-while-revalidate. The value is true the instant you set it.
Open the same app on a phone and the sidebar is whatever the phone last remembered. Client state lives on the device, not on the user.
The synchronous property is the one that changes everything. All the apparatus the server-state library shipped (loading, error, retry, cache key identity, garbage collection) was a tax on the network being slow and unreliable. The network isn't in the picture here, so the tax doesn't apply.
The scope is narrower than it looked back in the Redux-for-everything era. Most of what used to feel like "client state" was a remote API response in disguise. Subtract that and you're left with these:
Modals, drawers, dropdowns, tabs, hover and focus tracking. Visual chrome that exists only for the current view.
What the user is typing before they submit. Once it's submitted, the server owns it. Until then, no one else needs to know.
Theme, density, sidebar collapsed, last-used layout. Persisted on the device, never reconciled with anyone else.
Which row is highlighted, which tab is active, which page of a paginated list. State that describes how the user is looking at data, not the data itself.
Each one is small, scoped, and synchronous. The reason hand-rolled state management felt heavy in the old days wasn't that this kind of state is hard. It's that we mixed it with the other kind, and the other kind dragged everything down with it.
Once client state is small and synchronous, the question stops being which tool do I reach for and starts being how far does this value need to travel. That's the only question worth asking up front, and it has five answers, ordered from cheapest to most expensive.
Walk down the ladder one rung at a time:
useState. The default. If only one component needs it, the conversation ends here.The right level is usually one rung lower than where you reach first. Most "global" state isn't global. It's two siblings that didn't get lifted, or it's actually URL state pretending to be in-memory.
The web shipped with a state container before React did, and most teams forget it. The URL is durable, shareable, debuggable, and free. Anything that describes what the user is looking at belongs there before it belongs in any store:
The test is the same as before: would the user reasonably expect to send a link? If yes, the value lives in the URL, with any store reading from it rather than the other way around. Skip this and users hit refresh and lose the view they were looking at, a state bug they'll blame on you.
Three things commonly end up in a client store that don't belong there:
Cached server responses
A list of users, posts, products. The moment it lives in your client store, you have rebuilt a worse server-state cache. Hand it back to the server-state layer.
Anything keyed by user identity
"Current user", "their permissions", "their org". This data is owned by the server, ages on the server, and any other tab can change it. Server state, full stop.
Derived values that should be computed
"isSelected", "filteredCount", "hasUnsavedChanges". Storing the derivation freezes a snapshot that goes stale. Compute it from the underlying value at read time and the bug class disappears.
Each one looks fine in isolation. The damage shows up when an admin updates a user on screen A and the client store on screen B keeps showing the old name, or when a logout doesn't reset the wrong half of the app.
One case slips through the rules above. You have a legitimate piece of client state (a selectedUserId, an expanded row id, a filter chip the user clicked). It's local, synchronous, yours alone. The thing it references lives on the server, though, and that creates a tension the rest of this section hasn't covered.
The reflex is to keep the two in sync with useEffect:
If the selected user gets deleted on another tab, the next refetch drops them from users, the effect runs, the selection clears. It works. It's also fragile: every server change becomes a render, then an effect, then another render, with edge cases stacked at every step.
The cleaner move is to derive the rendered truth at read time:
No effect, no extra render. If the user reappears in a later refetch, the selection comes back on its own. The pattern generalizes: when a client value only makes sense in the context of server data, treat the id as an input and compute what the UI actually shows from both. A useEffect that syncs state to state is almost always a sign you should be deriving instead.
For the long version, TkDodo's Deriving Client State from Server State is the post that named this pattern.
That's the whole shape of client state. Local, synchronous, scoped one rung at a time, with the URL doing more work than most teams give it credit for.
Section 4 listed five places client state can live: component-local, lifted, module store, URL, browser storage. This section shows the code for each, in order. Most production state never needs more than the first two once server state has moved out.
The default for any new piece of client state is useState, scoped to the component that owns it.
If only SearchBox reads or writes the value, you're done. No store, no Context, no library.
When two siblings need the same value, lift it to their nearest common parent and pass it down.
Still no library. Most production codebases hold far more state at this level than developers realize, because the reflex is to reach for a store before trying to lift.
Reach for useReducer when the next value depends on the previous one in non-trivial ways, or when state transitions read more clearly as named actions than as setter calls. Multi-step wizards, undo/redo, complex form drafts; anything where the transition between states is the thing you keep reasoning about.
useReducer is still local state. The reducer is just a clearer way to write the transitions.
Eventually a value gets read across distant parts of the tree. The classic ones: current theme, auth-aware UI flags, a sidebar any screen can toggle, a "command palette is open" flag. Lifting these to the nearest common parent often means lifting them to the root, and now every component in between is forwarding props it doesn't care about.
That's when a global store starts to make sense, and the first thing to reach for is React's built-in Context.
For a single value that rarely changes (theme, locale, the current user) Context works. The trap is using Context as a general-purpose store. Every consumer of a Context re-renders on any change to the Provider's value, regardless of which field it actually reads. Put a frequently-changing object in Context ({ user, sidebar, modals, drafts }), and a keystroke on a draft input wakes up every component that touches any field on that object.
Splitting into one Context per concern helps, but the tree of nested Providers gets ugly fast:
And any value that needs reading from outside React (a service, a route guard, a localStorage rehydrator) still needs a separate way in.
This is the gap purpose-built state libraries fill. Redux, MobX, Recoil, Jotai, Valtio, Zustand all answer the same question: how do I share a value across the tree without prop-drilling and without re-rendering every consumer on every change?
When I saw that Zustand had pulled even with Redux on weekly downloads sometime in early 2026, I knew it wasn't random. A library that started as a 3-day prototype doesn't catch the one that defined React state management for the better part of a decade by accident. Put the two side by side and the reason starts to show:
| Library | Stars | Weekly downloads | Last updated | Created | Install size |
|---|---|---|---|---|---|
| redux | 61,453 | 32,899,689 | 2 years ago | 15 years ago | 283 kB |
| zustand | 57,854 | 32,295,690 | a month ago | 7 years ago | 92.8 kB |
Data as of April 2026
Source: npmtrends - redux vs zustand. Weekly downloads are essentially tied at ~32M each, with recent weeks tipping toward Zustand. Stars are within 6%. But Redux hasn't shipped a release in two years, and weighs three times as much on install. Half the age, a third the size, an order of magnitude fewer open issues and pulling the same install volume.
That's not a marketing milestone. It's the community catching up to the split this chapter walked through. Once server state moves to TanStack Query (or SWR, or RTK Query), the side Redux was built for shrinks. What's left is a sidebar toggle, a theme, a draft. None of it needs middleware, time-travel, or a reducer DSL. When this side needs a library at all, it needs the smallest one that stays out of the way.
That's the slot Zustand fits. A few hundred lines, no Provider wrapping, no action types, no thunks. You declare a store, read it with a selector, write it with a setter.
A component reads only what it touches, and re-renders only when that slice changes.
The (s) => s.sidebarCollapsed selector is what fixes the Context re-render problem: a theme change doesn't re-render SidebarToggle. It's also the entire mental model. Pick a slice, get a value, set it. The persist middleware writes the store to localStorage and rehydrates it on next load, which handles the persistence case for anything that should survive a reload.
There's one more place client state can live, and most teams forget about it. Anything the user might paste into Slack belongs in the URL before it belongs in any store.
The filter survives a refresh, deep-links cleanly, and any TanStack Query hook keyed by ['users', { role }] picks up the change automatically. URL drives the cache, not the other way around.
The hand-rolled hook is fine for one filter. Add a sort column, a page number, a tab id, and the URLSearchParams plumbing starts repeating. nuqs is the library that takes that plumbing off your plate. It treats query params like useState, with type-safe parsers and defaults, so URL state reads the same as any other client state hook.
For a screen with several params that move together (filters, sort, page), useQueryStates batches reads and writes so a single update touches the URL once:
Setup is a one-time NuqsAdapter provider near the root of your app (the docs cover Next.js App Router, Pages Router, React Router, and Remix). After that, every URL-backed value reads like local state, and the URL stays the source of truth.
That covers every rung on the ladder. None of it is much code, and most apps spend most of their time on the first two.
The Zustand examples above (create, persist, a selector or two) are what AI tooling reproduces on day one. The places the model gets it subtly wrong are the same places that hurt three months later: selectors that return a fresh object on every read and re-render the component on any state change, persist middleware writing fields that shouldn't survive a reload, derived values stored instead of computed, one giant store where slice-based organization would have read clearer.
The Core Concepts page in the Zustand docs is the one to read end-to-end before you commit to a store design. It's short, maintainer-written, and covers the parts of the API that decide whether the store stays clean as it grows. Same point as the TanStack Query reads in Section 3: the model writes the code, you're still the one reviewing it, debugging it on call, and owning it in production.
I've been using TanStack Query and Zustand together for years since the day I learnt about Server State and Client State. Both libraries fit the side they sit on because they were designed for it. But choosing libraries is not the most important part. The model behind them is the part that lasts, and it's the whole point of this tutorial.
Server state is data your app reads but doesn't own: remote, async, eventually consistent, shared across clients. It needs a layer that runs the full cycle on every read and write: fetch, cache, dedupe, revalidate, mutate, invalidate. Client state is local, synchronous, and yours alone: a sidebar toggle, a theme, a draft. It needs scoping discipline (component-local first, lifted only when forced, a global store as a last resort, the URL whenever the value would survive a refresh), not infrastructure.
Get the split right and the library you reach for becomes a footnote.
| Server state | Client state |
|---|---|
| TanStack Query: what I reach for first | Zustand: minimal, no providers |
| SWR: smaller surface, Vercel's pick | Jotai: atom-first, fine-grained subscriptions |
| RTK Query: ships inside Redux Toolkit | Valtio: proxy-based, mutable feel |
| Relay: GraphQL at scale | useState + useContext: covers more than you'd think |
| Hand-rolled fetch + Map: fine for a small surface | useReducer: when actions matter more than values |
The choice between any two on the same column is taste, team familiarity, and what the codebase already runs. Pick whatever your team knows, then read its docs against the server-state cycle and the client-state scoping ladder.
Something will replace Zustand. Something will replace TanStack Query. The version of this chapter written in 2030 will have new names in the chart, and most of this section will read the same. New libraries get built when someone notices the older one was solving the wrong problem, or solving the right one at the wrong layer. Know the split and you can evaluate the next library on its own merits, instead of inheriting whichever one your last project happened to use.
Share this tutorial
About the author
NAB, Lead Engineer
I'm Vu, a Lead Engineer at NAB (National Australia Bank). I started on Home Lending products and now lead a team building for HICAPS, Australia's largest point of sale claiming service for private health insurance. Before NAB, I worked at startups and across a range of teams and stacks.
Upskills is where I share practical, real-world knowledge to help you build and ship projects better. Beyond tutorials, more content is coming: project showcases, interview prep, and AI tools as resources for your learning journey. I'm excited to share what I've learned and keep learning together as we build cool things.
Practical web development deep-dives, every other week, written by an engineer who ships them. No fluff, no hot takes.
We respect your privacy. Unsubscribe anytime.
// app/providers.tsximport { QueryClient, QueryClientProvider } from '@tanstack/react-query';const queryClient = new QueryClient({defaultOptions: {queries: {staleTime: 60_000, // 1 min before a re-read triggers a background refetchgcTime: 5 * 60_000, // entry lingers 5 min after the last subscriber unmountsrefetchOnWindowFocus: true,
function SearchBox() {const [query, setQuery] = useState('');return <input value={query} onChange={(e) => setQuery(e.target.value)} />;}
function Toolbar() {const [query, setQuery] = useState('');return (<><SearchBox query={query} onQueryChange={setQuery} /><ResultCount query={query} /></>);}
type Action =| { type: 'next' }| { type: 'back' }| { type: 'jump'; step: number }| { type: 'reset' };function wizardReducer(state: { step:
const ThemeContext = createContext<'light' | 'dark'>('light');export function ThemeProvider({ children }: { children: React.ReactNode }) {const [theme, setTheme] = useState<'light' | 'dark'>('light');return <ThemeContext.Provider value={theme}
<ThemeProvider><AuthProvider><SidebarProvider><ModalProvider><CommandPaletteProvider><DraftsProvider><App /></DraftsProvider></CommandPaletteProvider></ModalProvider>
function useUsers(filters: UserFilters) {return useQuery({queryKey: ['users', filters],queryFn: () => api.fetchUsers(filters),});}
function useUserPreview(filters: UserFilters, userId: string) {return useQuery({queryKey: ['users', filters],queryFn: () => api.fetchUsers(filters),select: (users) => users.find((u) => u.id === userId),});}
function useUser(id: string) {return useQuery({queryKey: ['users', id],queryFn: () => api.fetchUser(id),});}
import { useMutation, useQueryClient } from '@tanstack/react-query';function useChangeRole() {const queryClient = useQueryClient();return useMutation({mutationFn: ({ userId,
const { data: users } = useUsers();const [selectedUserId, setSelectedUserId] = useState<string | null>(null);useEffect(() => {if (selectedUserId && !users?.some((u) => u.id === selectedUserId)) {setSelectedUserId(null);
const selectedId = users?.some((u) => u.id === selectedUserId)? selectedUserId: null;
// stores/ui-store.tsimport { create } from 'zustand';import { persist } from 'zustand/middleware';type UIStore = {sidebarCollapsed: boolean;toggleSidebar: () => void;theme: 'light' | 'dark';
function SidebarToggle() {const collapsed = useUIStore((s) => s.sidebarCollapsed);const toggle = useUIStore((s) => s.toggleSidebar);return <button onClick={toggle}>{collapsed ? 'Expand' : 'Collapse'}</button>;}
'use client';import { useSearchParams, useRouter } from 'next/navigation';export function useRoleFilter() {const params = useSearchParams();const router = useRouter();const role = params.get('role') ?? 'all';
'use client';import { useQueryState, parseAsInteger, parseAsStringEnum } from 'nuqs';export function UsersToolbar() {const [role, setRole] = useQueryState('role',parseAsStringEnum(['all', 'admin', 'member']).withDefault('all'),
import { useQueryStates, parseAsInteger, parseAsString } from 'nuqs';const [filters, setFilters] = useQueryStates({role: parseAsString.withDefault('all'),sort: parseAsString.withDefault('name'),page: parseAsInteger.withDefault(1),});// setFilters({ role: 'admin', page: 1 }) -> ?role=admin&sort=name&page=1