State & data layer

State & data layer

A decision framework for where state lives in a Nuxt application — component, useState, Pinia, a query cache, the URL, cookies, browser storage or the server — and how the pages in this section map to it.

"Where does this state live" is the question behind most frontend architecture interviews. In a Nuxt app the honest answer is "it depends on scope, lifetime and who else needs it", and a senior candidate can name the eight places state can live and the trade-off of each in one breath. This section is that breath, expanded.

Know

  • Component state (ref in setup): the default; local, dies with the component. Lift it only when a sibling or a distant component needs it.
  • Shared page state (useState): SSR-safe, keyed, serialised into the payload, no behaviour attached. Right for small cross-component values; wrong for anything with actions or a lifecycle beyond the page.
  • Application state (Pinia): one store instance per app and per request, with actions, getters, devtools and plugins; hydrated from the payload. Right for the current user, a cart, cross-page filters, feature flags.
  • Server state cache (useAsyncData, or a query library such as TanStack Query or Pinia Colada): data owned by the server that the client caches, deduplicates, invalidates and refetches. Do not copy it into a store; wrap it in a cache with a key.
  • URL state (route.query, params): anything the user should be able to share, bookmark or navigate back to: filters, pagination, selected tab, sort order.
  • Cookies (useCookie): preferences the server render must know (locale, colour mode, consent) and the session itself.
  • Browser storage (localStorage, IndexedDB, Cache API): per-device persistence that the server cannot see; drafts, offline data, large caches. Never anything secret, never anything the first render depends on without a hydration strategy.
  • The server (database, KV, session store): the source of truth for everything durable and shared between users. Every other layer is a cache of it or a view over it.

How the pages fit

PageQuestion it answers
Pinia"When do you use a store, and how does Pinia work in SSR?"
Server state and caching"How do you cache, invalidate and update data from the API on the client?"
Forms and validation"How do you build forms with one schema for client and server?"
Realtime and sync"WebSockets, SSE or polling, and how do you keep clients in sync?"
URL and persistence"What goes in the URL, a cookie or localStorage, and how do you persist without breaking hydration?"

How it works

The decision as a table, which is how I would draw it on a whiteboard:

NeedPlaceBecause
Modal open, input draftref in the componentnobody else cares
Sidebar collapsed, across components on one pageuseState('ui:sidebar')shared, tiny, SSR-safe
Current user, permissionsPinia store, hydrated from the payloadbehaviour (login, logout), read everywhere
Product list from the APIuseFetch / query cache keyed by paramsserver-owned; cache and invalidate, do not copy
Active filters, page numberURL queryshareable, back button works, server can render it
Locale, theme, cookie consentuseCookiethe server render needs it
Unsent comment draftlocalStorage, restored after hydrationper device, survives reload, nothing depends on it at first render
Orders, inventorydatabase behind server/apidurable, shared, authoritative
app/composables/useProductFilters.ts
// URL state with typed parsing: shareable, back-button friendly, rendered by the server.
import { z } from 'zod'

const schema = z.object({
  q: z.string().default(''),
  page: z.coerce.number().int().min(1).default(1),
  sort: z.enum(['price', 'name']).default('name'),
})

export function useProductFilters() {
  const route = useRoute()
  const router = useRouter()
  const filters = computed(() => schema.parse(route.query))
  function update(patch: Partial<z.infer<typeof schema>>) {
    router.replace({ query: { ...route.query, ...patch, page: patch.page ?? 1 } })
  }
  return { filters, update }
}
Gotcha· Copying server data into a store

A products array in a Pinia store filled by an action is a second source of truth with no invalidation policy. It goes stale, it leaks between users on the server if the action runs at module level, and it duplicates what useAsyncData or a query cache already does with keys and refetching. Store client-owned state; cache server-owned data.

Exercise

Exercise
  • Take a page from one of your projects and classify every piece of state in it against the table above. Move at least one thing that is in the wrong place.
  • Implement useProductFilters and confirm that the server render honours ?page=2&sort=price and that the browser back button steps through filter changes.

Be able to say

Be able to say· How do you decide where a piece of state lives?

"By scope and lifetime. Local UI state stays a ref in the component. State that several components on a page share is useState, which is SSR-safe and serialised into the payload. State with behaviour that the whole application reads, the user, a cart, is a Pinia store, one instance per request on the server and hydrated on the client. Data that the server owns is not state I copy; it lives in a keyed cache, useAsyncData or a query library, with an invalidation policy. Anything shareable or bookmarkable goes in the URL, anything the server render needs to know goes in a cookie, and browser storage is for per-device persistence that the first render does not depend on."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on every page of this section.