Frontend / Guide

Server state, client state, URL state and forms - one pick each

Most state bugs come from the wrong kind of state in the wrong tool: server data copied into a store and never refetched, a filter kept in memory instead of the URL, a form wired into a global store. Sort the state into four kinds first, and the tool for each is nearly a formality.

About 8 min read. Versions verified September 2026.

The verdict

TanStack Query for server state, framework-native state or Zustand for client state, the URL for anything a user might share or bookmark, and React Hook Form with Zod for forms. Never put server data in a global store: it is a cache with a staleness problem, and a query library already handles the loading, errors, retries and invalidation you would otherwise write by hand.

Four kinds of state, four different tools

Ask who owns the value, how long it lives, and whether a second person should see it. Each piece of state lands in exactly one row.

Kind What it is Pick Why
Server state Data the browser only borrows: lists, records, the current user. Somebody else can change it while you look at it. TanStack Query 5 A cache, not a store. Staleness, refetching and retries are the library's job.
Client state State the browser invented and nobody else needs: a sidebar toggle, a theme, a wizard step. useState, then Zustand 5 Most of it belongs in the component that renders it. Add a store only when routes share it.
URL state Anything a user might share, bookmark or refresh: filters, sort, page, search, the active tab. nuqs 2 or the router Free persistence and free sharing. A filter in memory dies on refresh; one in the query string survives.
Form state Field values, dirty flags, errors and submission status, alive only while the form is open. React Hook Form 7 + Zod 4 Uncontrolled inputs keep keystrokes off the render path, and one schema validates on both sides.

Gotcha: the same data can be two kinds. The product list is server state; the selected product is URL state; the edits in the open form are form state until submit. Tools change at those boundaries, not at file boundaries.

Server state: cache, do not store

Each of these keeps a cache keyed by request, tracks freshness, deduplicates requests in flight, and refetches on focus. That is exactly the feature a global store lacks.

TanStack Query

5.103 - the default

Query keys, stale times, background refetching, infinite queries, mutations with optimiztic updates, and devtools that show the cache. One core with adapters for React, Vue, Solid, Svelte and Angular; it does not care where the promise comes from. tanstack.com/query

SWR

2.5 - smaller, React only

Vercel's stale-while-revalidate hook: one useSWR call with a key and a fetcher. It stops short of TanStack Query on mutations and devtools, so pick it for a read-heavy app where every dependency is counted. swr.vercel.app

RTK Query

2.12 - if Redux is already there

Ships inside Redux Toolkit: define an API slice and it generates hooks, caches by argument and invalidates by tag. The right pick when the codebase is on Redux; not a reason to adopt Redux. redux-toolkit.js.org

Apollo Client / urql

4.3 and 5.0 - GraphQL only

Both understand a GraphQL schema, so a mutation that returns an entity updates every query holding it. Apollo is larger, with a normalized cache by default; urql is leaner. REST goes to TanStack Query. apollographql.com

When the framework fetches on the server, the client cache often has nothing to do. React Server Components read data during render and Server Actions write it, so a Next.js page that renders a list and revalidates after a mutation never needs a client query; SvelteKit load, Nuxt useFetch and TanStack Router loaders do the same on their frameworks. Keep TanStack Query for what is genuinely client-driven - polling, infinite scroll, updates without a navigation - and let the loader own the rest; running both on the same data is how two caches end up disagreeing. See rendering models.

Gotcha: a Server Action refreshes nothing by itself. Call revalidatePath or revalidateTag after the write, or the page keeps rendering the old list.

Client state: start smaller than you think

Once server state has moved to a cache, most apps have very little client state left, and most of that is local to one component. Escalate in order - useState, lift it to a parent, then a store.

useState + context

React 19.3 - start here

Component state first, lifted to the nearest common parent when siblings share it, and context for values that rarely change - theme, locale, the current user. Context is not a store: every consumer re-renders on every change. react.dev

Zustand

5.0 - the default store

One create() call, no provider, selectors so components re-render only for the slice they read, and a store you can use outside React. The pick when routes share state; the full case is in Zustand vs Redux. zustand.docs.pmnd.rs

Jotai

3.0 - atoms, bottom-up

Tiny pieces of state composed into derived atoms, each component subscribing only to what it reads. The right model when state is a graph of dependent values - a spreadsheet, a builder. 3.0 shipped in September 2026, mostly compatible with 2.x. jotai.org

Redux Toolkit

2.12 - for the large existing codebase

Prescribed structure, named actions, time-travel devtools and RTK Query in one package. Stay on it when you are already on it and many developers touch the state layer; do not start a new small app on it. redux-toolkit.js.org

Pinia

4.0 - the Vue store

Vue's official store, built from ref and computed with devtools and a plugin API. 4.0 is ESM-only and needs @vue/devtools-api installed alongside it. Server data still goes to TanStack Query. pinia.vuejs.org

Runes, stores and signals

Svelte 5, Solid 1.9, Angular 22 - native

Outside React the framework already ships fine-grained reactivity. Svelte 5 runes work in plain .svelte.ts modules, Solid's createStore gives nested reactive objects, and Angular signals plus signalStore from NgRx 22 cover the shared case. svelte.dev

Gotcha: derived state is not state. If a value can be computed from other state - a total, a filtered list, an isValid flag - compute it in a selector, a useMemo or a $derived, never store it.

URL state: the store you forgot

Filters, sort orders, pagination, search terms, the active tab: all of it belongs in the query string, where it is shareable, survives a refresh, and gives you the back button for free. The trick is reading it with types instead of parsing strings by hand.

nuqs

2.10 - typed search params for React

useQueryState is useState for the URL: a key, a parser with a default, and the value stays in sync with the query string. Adapters cover Next.js, React Router, TanStack Router and plain React. nuqs.dev

TanStack Router

1.170 - search params as a schema

Every route declares validateSearch, so search params are parsed, typed and defaulted before the component renders, and a Link to the route is type-checked against them. Nothing to add if you are already on the router. tanstack.com/router

SvelteKit page params

SvelteKit 2 - already there

The url and params are in every load function and on the page state, so a filter reads from url.searchParams on server and client alike. Nuxt has the same in useRoute(). svelte.dev/docs/kit

A typed read with nuqs. The parser decides the type and the default, so a stale or hand-edited URL degrades to something valid:

import { useQueryState, parseAsInteger, parseAsStringLiteral } from 'nuqs'

const sortOptions = ['newest', 'price-asc', 'price-desc'] as const

export function ProductFilters() {
  const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
  const [sort, setSort] = useQueryState(
    'sort',
    parseAsStringLiteral(sortOptions).withDefault('newest')
  )
  // page is number; sort is 'newest' | 'price-asc' | 'price-desc'
  return <button onClick={() => setPage(page + 1)}>Next page</button>
}

Gotcha: the URL is public - nothing sensitive, large or private goes in it. In the Next.js App Router, nuqs updates are shallow by default, so pass shallow: false when a Server Component reads the param.

Forms and validation

Form state is short-lived, local and high-frequency. A form library owns registration, dirty and touched flags, errors and submission; a schema library owns what valid means.

React Hook Form

7.88 - the default

Uncontrolled by default, so typing does not re-render the form, with register() for native inputs and Controller for component-library inputs. Resolvers plug in Zod, Valibot or ArkType. Pick it unless one of the next two solves a problem you actually have. react-hook-form.com

TanStack Form

1.33 - typed end to end, any framework

The same framework-agnostic core as Query and Router, with React, Vue, Solid, Svelte and Angular adapters and full inference of the form's value type. Pick it when the team is already on TanStack or the same form must run in two frameworks. tanstack.com/form

Conform

1.21 - progressive enhancement first

Built for Remix, React Router and Next.js Server Actions: the form works with JavaScript off, the schema validates on the server, and Conform mirrors the errors back into the client. The pick when Server Actions are the submission path. conform.guide

VeeValidate / Superforms

4.15 and 2.30 - Vue and SvelteKit

VeeValidate is the Vue form library, with composition-API functions and a bridge for Zod and Valibot. Superforms is the SvelteKit one: it runs the schema in load and in the form action, then hydrates a typed form store on the client. superforms.rocks

Validation Version Pick it when
Zod 4.6 The default. The most resolvers and integrations, top-level formats such as z.email(), and zod/mini for the bundle-sensitive case.
Valibot 1.5 A modular, pipe-based API where you import only the validators you use. Pick it when the client bundle is measured.
ArkType 2.2 Schemas written in TypeScript's own type syntax inside strings, validated at runtime. Pick it when the schema should read like a type.

One schema file, imported by the form and by the server action. The client run is a convenience; the server run is the security boundary:

// lib/schemas/signup.ts - shared
import { z } from 'zod'

export const SignupSchema = z.object({
  email: z.email(),
  password: z.string().min(12),
  plan: z.enum(['free', 'team']),
})
export type Signup = z.infer<typeof SignupSchema>

// app/signup/form.tsx - client
const form = useForm<Signup>({ resolver: zodResolver(SignupSchema) })

// app/signup/actions.ts - 'use server', validated again
export async function signup(formData: FormData) {
  const result = SignupSchema.safeParse(Object.fromEntries(formData))
  if (!result.success) {
    return { errors: z.flattenError(result.error).fieldErrors }
  }
  await createAccount(result.data)
}

Gotcha: validate on the server even when the client already did. Anyone can post to the action without the form. One shared schema file is what stops the two sides from drifting.

Gotchas

Six symptoms that show up in code review, each with the state mistake behind it.

Symptom Cause and fix
A list shows deleted items until reload, and the store has loading flags for every entity Server data in Redux or Zustand. Move it to TanStack Query - or RTK Query if Redux stays - delete the loading flags, and invalidate the query key after each mutation.
A total or a filtered list is sometimes wrong after an edit Derived state stored as state. Compute it where it is read - a selector, useMemo, $derived, a Vue computed - and store only the inputs.
Typing in a form re-renders unrelated parts of the app Form values in a global store or in context. Keep them in the form library, which holds them in refs, and emit only the submitted result.
A page makes a request, waits, then makes another the first one could have predicted A waterfall from nested useQuery: the child cannot start until the parent renders. Hoist the queries to the route with useQueries or a loader, or fetch on the server where round trips are cheap.
An event handler acts on the value from the previous render A stale closure: the handler captured state from the render that created it. Read the latest value from a ref or the store - useStore.getState() in Zustand - and use useEffectEvent inside effects.
The UI updated instantly, the request failed, and the wrong value stayed on screen An optimiztic update without rollback. In TanStack Query, onMutate snapshots the cache, onError restores it and onSettled invalidates; React 19's useOptimiztic reverts on its own when the action throws.

Our pick

TanStack Query, the smallest client store that works, the URL, and React Hook Form with Zod

Sort every piece of state into one of the four kinds before choosing a tool. Server state goes to TanStack Query 5, or stays in the framework loader when the server already fetches it. Client state starts as useState and graduates to Zustand 5 only when routes share it - Pinia 4 on Vue, the native runes, stores and signals elsewhere. Anything a user might share, bookmark or refresh goes in the URL through nuqs or the router's typed search params.

Forms get React Hook Form 7 with a Zod 4 schema that the server action imports and runs again - VeeValidate on Vue, Superforms on SvelteKit, Conform when progressive enhancement matters. And the rule that makes the rest work: never put server data in a global store. The moment you do, you are writing a cache by hand, and the query library was already better at it.

Keep going

The client-store decision in full is in Zustand vs Redux, and the hook rules that decide whether a store causes re-render storms are in the React hooks cheatsheet. The inference patterns behind a shared Zod schema are in the TypeScript cheatsheet.

Whether the server state arrives as REST or GraphQL changes which cache you pick - REST vs GraphQL makes that call - and rendering models covers when a framework loader replaces the client cache. The JavaScript checklist has the runtime-validation and safe-storage rules this page assumes.