Skip to content

Tier 2a · Chapter 5: State Management & Persistence

JS/TS Fullstack (Nuxt) Track · ~30 min read + exercise


Why this chapter exists

A fullstack app has state in two places — on the client (what the UI is currently showing and tracking) and on the server (the persistent source of truth) — and the architecture of how those relate is where a lot of fullstack complexity and a lot of bugs live. A backend service has persistence; a SPA has client state; a Nuxt app has both, plus the boundary between them, plus the SSR wrinkle that some state is created on the server and rehydrated on the client. This chapter is about managing client state with Pinia, persisting and caching on the server side, and — the genuinely hard part — keeping the two consistent across the boundary without the client and server drifting into disagreement.

The throughline: client state and server state are different things with different jobs, and most fullstack state bugs come from confusing them or letting them drift apart. The server holds the source of truth (persisted, authoritative); the client holds a view of that truth plus UI-only state (what's expanded, what's being edited). Treating client state as authoritative (when the server is the truth) or duplicating server state into the client and forgetting to keep it in sync (the consistency problem from Foundations Chapter 3, now across the network boundary) is where it goes wrong. Good fullstack state architecture keeps clear which state is which and manages the boundary between them deliberately.

What this chapter covers

  • Client state with Pinia — what belongs in a store, and the SSR-and-hydration concern.
  • What's client state vs. server state — the distinction that prevents most state bugs.
  • Server-side persistence and caching — the source of truth and making it fast (the consistency decision returns).
  • Consistency across the boundary — optimistic updates, reconciliation, and keeping client and server in agreement.

Client state with Pinia

Pinia is the standard state store for Vue/Nuxt — a place to hold state that multiple components share, beyond what props and local component state handle. The architectural question isn't how to use Pinia (the docs cover that) but what belongs in it, because over-stuffing the store is a common mistake. A store is for state that's genuinely shared across components and outlives a single component — the current user, a shopping cart, app-wide UI state. State local to one component (a form's in-progress values, whether a dropdown is open) belongs in that component, not the global store; hoisting everything into Pinia makes the store a tangled global the way module-level mutable state tangles any app.

The Nuxt-specific wrinkle is SSR and hydration (chapter 2). In a Nuxt app, a Pinia store can be initialized during server-side rendering, and its state must transfer to the client on hydration — otherwise the server renders with one state and the client hydrates with another, a hydration mismatch. Nuxt and Pinia handle the transfer (the store's state is serialized into the page and rehydrated, like fetched data), but the same boundary discipline from chapter 1 applies: state that crosses the SSR boundary must be serialization-safe, and you must not initialize a store with client-only data (something from localStorage, say) during SSR where it doesn't exist. The store is universal-ish state, and it crosses the boundary, so it obeys the boundary's rules.

The practical guidance: put genuinely shared, cross-component state in Pinia; keep component-local state local; and respect that store state crosses the SSR boundary, so keep it serialization-safe and don't seed it with environment-specific data during SSR.


What's client state vs. server state

This is the distinction that prevents most fullstack state bugs, and it's worth being precise about because the two get conflated constantly. Server state is data that lives on the server, is persisted, and is the source of truth — tickets, customers, anything in your database. Client state is what the browser is currently holding, which divides into two kinds: a cached view of server state (the tickets you fetched and are displaying), and UI-only state (which row is expanded, what's in the edit form, the current filter) that exists only in the browser and was never on the server.

The bugs come from blurring these:

Treating cached server state as authoritative. The tickets in your Pinia store are a copy of server state, fetched at some moment — they can be stale (the consistency spectrum from Foundations Chapter 3, now between server and client). Treating that copy as the truth — letting the user act on it as though it's current, never re-syncing — is how the UI shows data that no longer matches the server. The client's copy is a view, not the truth, and the architecture must account for it going stale.

Duplicating server state into client state and forgetting to sync. Copying fetched data into Pinia and then mutating the Pinia copy without telling the server (or vice versa) is how the two drift. Every copy of data is a consistency obligation (the foundations' lesson), and a client cache of server state is exactly such a copy.

Putting UI-only state on the server, or server state only in the client. UI-only state (expanded rows, draft form values) usually doesn't belong on the server — it's ephemeral browser state. Conversely, anything that must persist and be the truth can't live only in the client, which evaporates on refresh. Knowing which is which tells you where each piece of state belongs.

The discipline: for each piece of state, ask is this the source of truth (server state, persisted) or a view/UI concern (client state)? — and treat it accordingly. This classification, made deliberately, is most of what keeps fullstack state coherent. It's the consistency-placement instinct from Foundations Chapter 3, applied to the client/server divide.

Modern Nuxt data-fetching tools blur the line helpfully here: useFetch/useAsyncData (chapter 3) manage cached server state for you — fetching, caching, and refreshing the client's view of server data — which is increasingly the recommended way to handle server-state-on-the-client rather than manually copying fetched data into Pinia. The emerging pattern is: let the data-fetching layer own the cached view of server state, and reserve Pinia for genuine client/UI state. Knowing the distinction is what lets you put each in the right tool.


Server-side persistence and caching

The server side holds the source of truth, and the persistence and caching concerns here are the same as any backend's — now living in your Nitro server layer (or a database your server routes talk to). The architecture: server routes reach data through a clear data-access boundary (a repository or data-access module in server/), keeping the database details out of the route handlers (the boundary lesson, in Nitro), so the persistence layer is isolated and the routes stay thin (chapter 4).

When a single action touches several rows that must move together, the persistence layer's job is to make them move atomically — all or nothing — which is the consistency lesson from Foundations Chapter 3 enforced by the database. Resolving a ticket is a good example: it sets the ticket's status, writes a resolution record, and decrements the assigned expert's open-ticket count. If those three were three separate writes and the process died between them, you'd have a resolved ticket with no resolution, or a count that no longer matches reality. Wrap them in one transaction so they commit together or not at all:

typescript
// server/services/resolveTicket.ts — three writes, one atomic commit
await db.$transaction(async (tx) => {
  await tx.ticket.update({ where: { id }, data: { status: "resolved" } });
  await tx.resolution.create({ data: { ticketId: id, summary, resolvedBy: expertId } });
  await tx.expert.update({ where: { id: expertId }, data: { openTickets: { decrement: 1 } } });
});
// Commit succeeded → only now dispatch the slow external call, outside the transaction:
await notifyCustomer(id);

The customer notification is deliberately outside the transaction. It's a slow external call (email, a third-party API), and holding a database transaction open across a network call is how you exhaust the connection pool — and if the notification fails, you don't want to roll back a resolution that genuinely happened. So the rule is: keep the atomic database work inside the transaction, and fire the slow, external, after-the-fact effects only once it has committed (which is exactly the async-workflow handoff the next chapter builds on). (Flagged: the $transaction shape shown is Prisma's; Drizzle, Knex, and other ORMs expose an equivalent transaction callback — current-ecosystem specifics, not from the book set.)

Caching on the server side is the consistency decision from Foundations Chapters 1 and 3, returning in its server form: a cache (often Redis, or Nitro's built-in storage layer / cached functions) is a copy of data kept fast to read, and every cache is a deliberate consistency trade-off. The same per-piece-of-data question applies — how much staleness can this tolerate? Cache freely with a short TTL for staleness-tolerant data, cache carefully with invalidation for data that must be fresh-ish, don't cache the data that must be exactly right. Nitro offers route-level and function-level caching that makes this convenient (cache a server route's response, cache an expensive function's result), which is powerful and carries the same caveat: cache invalidation is the hard part, and a cached server route serving stale data is the same bug as any stale cache. (The full caching/consistency treatment is Foundations Chapter 3; here the point is that it lives in your Nitro server layer and uses Nitro/Redis mechanisms.)

There's also a fullstack-specific caching layer worth naming: the SSR payload and the data-fetching cache are a kind of caching — the data fetched during SSR is cached and transferred to the client (chapter 3), and useAsyncData caches by key so repeated navigations don't re-fetch. This client-side caching of server state is the same consistency trade-off one more time (the client's cached view can be stale), managed by the data-fetching layer's refresh and cache-invalidation controls. So caching in a Nuxt app happens at several layers — server-side data cache, SSR payload, client data-fetch cache — and each is a copy with the same staleness-vs-speed trade-off, to be placed deliberately on the consistency spectrum.


Consistency across the boundary

The hardest fullstack state problem is keeping client and server consistent across the network, and it's the optimistic-update tension from Foundations Chapter 1 made concrete. When a user acts — resolves a ticket, edits a field — you have a choice about how the client and server stay in agreement:

The consistent approach: act, wait, then update. The client sends the change to the server, waits for confirmation, and only then updates its view to reflect the confirmed truth. The client never shows state the server hasn't agreed to — fully consistent — but the UI feels slower, because every action has a round-trip's latency before the user sees the result.

The optimistic approach: update immediately, reconcile later. The client updates its view immediately, assuming the server will agree, sends the change, and reconciles if the server disagrees (rolling back on failure). The UI feels instant, but now the client's view and the server's truth can diverge in the window before confirmation, and you've signed up for the genuinely hard work of reconciliation — what happens when the optimistic update was wrong? You traded consistency for perceived performance, and you pay it back in rollback logic and edge cases.

This is exactly the consistency trade-off from Foundations Chapter 1 and Chapter 3, now between client and server: optimistic updates are a client-side cache of an assumed-successful server state, and like any cache, the hard part is what happens when the copy and the truth disagree. The guidance is the foundations' guidance: use the consistent approach by default (simpler, correct), and reach for optimistic updates deliberately where the perceived-performance win justifies the reconciliation complexity — a "like" button, a reorder, low-stakes actions where being briefly wrong is cheap and recoverable. Don't make a payment or anything where a wrong optimistic state misleads the user about something costly. Match the approach to the cost of the client being briefly wrong — the consistency-spectrum placement, applied to UI updates.

The data-fetching layer helps here too: after a mutation, you can refresh the relevant useFetch/useAsyncData to re-pull the server's truth, which re-syncs the client's cached view — a clean reconciliation tool that keeps the client's view of server state honest after changes.

That's fullstack state: client state and server state are different things (source of truth vs. view + UI concern), and confusing them causes most state bugs; client state belongs in Pinia (genuinely shared) or the data-fetching cache (server-state views), respecting the SSR boundary; server persistence and caching live in the Nitro layer with the same consistency trade-offs as any backend; and consistency across the network is the optimistic-vs-consistent trade-off from the foundations, placed per action by the cost of being briefly wrong.


Practice

Budget about ninety minutes.

Exercise 1: Classify your state (20 min)

In a Nuxt app you work on, list the significant pieces of state and classify each: server state (source of truth, persisted), cached server state (a client view of server data), or UI-only client state. Flag anything misplaced — server state treated as authoritative in the client, UI state pushed to the server, or server-state copies in Pinia that could be the data-fetching cache instead.

Exercise 2: Place caching on the spectrum (35 min)

Find where your server layer caches (or could cache) — a Nitro cached route/function, a Redis cache, or the SSR/data-fetch cache. For each, decide where the data sits on the consistency spectrum and whether the caching (and its staleness window) is right. Flag any cache that serves data which must be fresh without a sound invalidation story.

Exercise 3: Decide an update's consistency approach (35 min)

Pick a user action that changes server state. Decide whether it should be a consistent update (act-wait-update) or an optimistic one (update-then-reconcile), justified by the cost of the client being briefly wrong. If optimistic, sketch the reconciliation — what happens when the server disagrees — and note how you'd re-sync (e.g. refresh the relevant fetch).

Deliverable

A short state-architecture note for one feature: each piece of state classified (server / cached-server / UI-only) and placed in the right tool, the caching decisions on the consistency spectrum, and one mutation's consistency approach (consistent vs. optimistic) chosen by the cost of being briefly wrong — with the reconciliation sketched if optimistic.

Check yourself

  • Distinguish server state, cached server state, and UI-only client state, and explain why confusing them causes bugs.
  • Explain what belongs in Pinia vs. the data-fetching cache vs. component-local state, and the SSR-boundary concern for store state.
  • Explain why every server-side cache is a consistency decision and how the staleness question places it.
  • Explain the optimistic-vs-consistent update trade-off and how to choose per action.

What a strong answer demonstrates: a strong answer classifies each piece of state cleanly — server (source of truth), cached server-state (a view), or UI-only — and puts each in the right tool, recognizing that most state bugs come from confusing them. The clearest signal of understanding is treating every cache as a consistency decision placed by the cost of staleness, and choosing optimistic vs. consistent updates by how costly it is for the client to be briefly wrong — with the reconciliation sketched when optimistic.

Going deeper (optional)

  • Designing Data-Intensive Applications (Kleppmann) — the replication and consistency chapters are the deep source for the client/server consistency and caching trade-offs here.
  • The Pinia and Nuxt documentation on state management, SSR state, and the data-fetching cache, and Nitro's storage/caching docs. (Flagged: Pinia, Nuxt SSR-state, and Nitro caching specifics are current-ecosystem knowledge, not from the course's book set; the consistency principles are from the foundations and Kleppmann.)
  • Primary sources (the theory beneath "every copy is a consistency decision"): Seth Gilbert & Nancy Lynch, "Brewer's Conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services" (ACM SIGACT News, 2002) — the CAP proof that frames why a cached client copy and the server truth can't both be instantly consistent and always available. And Theo Härder & Andreas Reuter, "Principles of Transaction-Oriented Database Recovery" (ACM Computing Surveys, 1983) — the origin of ACID, the guarantee behind keeping multi-row server writes atomic.

Key takeaways

  • Client state and server state are different things — server state is the persisted source of truth; client state is a view of it plus UI-only concerns. Most fullstack state bugs come from confusing them.
  • Put genuinely shared state in Pinia, let the data-fetching layer own cached server-state views, keep UI-only state local — and respect that store state crosses the SSR boundary (serialization-safe, no client-only seeding during SSR).
  • Server-side caching (Nitro/Redis, plus the SSR and data-fetch caches) is the consistency decision from Foundations Chapters 1 and 3 — every copy placed on the staleness spectrum, invalidation the hard part.
  • Consistency across the boundary is the optimistic-vs-consistent trade-off: consistent (act-wait-update) by default, optimistic (update-then-reconcile) only where the perceived-performance win justifies the reconciliation cost — chosen by how costly it is for the client to be briefly wrong.

Next: Async Workflows — backgrounding work from a Nuxt app, where Nitro's deployment model (server vs. serverless vs. edge) reshapes the queue-and-worker story.