State & data layer

Server state and caching

Treating API data as a cache rather than state — stale-while-revalidate, keys and invalidation, optimistic updates with rollback, pagination and infinite queries — with useAsyncData, TanStack Query and Pinia Colada compared.

Most bugs in data-heavy frontends are cache bugs wearing another name: a list that does not refresh after a save, a detail page showing the previous item for a frame, a request fired six times because six components asked. The vocabulary that fixes them, keys, staleness, invalidation, deduplication, optimistic updates, is what this page and the interview questions behind it are about.

Know

  • Server data is a cache, not state. The server owns it; the client holds a copy keyed by what it asked for, with a policy for when the copy is stale and how it is refreshed. Copying it into a store removes the policy.
  • Keys are the whole design: they encode every input that changes the result (['products', { page, sort, q }]). Two callers with the same key share one request and one entry (deduplication); changing an input changes the key and produces a new entry.
  • Stale-while-revalidate: serve the cached copy immediately, refetch in the background when it is older than staleTime, swap it in when it arrives. That is the model behind TanStack Query, Pinia Colada, Nitro's swr route rule and the HTTP header of the same name.
  • Invalidation is triggered by events you know about: after a mutation (invalidateQueries({ queryKey: ['products'] })), on window focus, on reconnect, on an interval, or on a server push. Time-based expiry alone is a fallback, not a strategy.
  • Optimistic updates: apply the expected result to the cache before the server responds, roll back on error, and reconcile with the server response or an invalidation on settle. Use them for actions with a high success rate and instant feedback needs (toggles, reordering, likes); avoid them for payments.
  • Pagination: offset (?page=3) is simple and jumps anywhere but drifts when items are inserted; cursor pagination (?after=<id>) is stable under inserts and is what infinite lists should use. Keep the previous page visible while the next loads (placeholderData: keepPreviousData), and treat "infinite query" as a list of pages with a cursor per page.
  • Nuxt's built-in layer: useAsyncData/useFetch cache per key in the payload and in memory for the app instance, deduplicate in-flight calls (dedupe), refetch on reactive key changes, and expose getCachedData for custom staleness. It is page-oriented and SSR-first; it has no background refetch on focus, no mutation state, no retries and no garbage collection policy.
  • Query libraries: TanStack Query (@tanstack/vue-query) adds staleTime/gcTime, retries with backoff, refetch on focus and reconnect, useMutation with optimistic helpers, useInfiniteQuery, devtools, and SSR through dehydrate/hydrate wired into the Nuxt payload. Pinia Colada is the Pinia-team equivalent built on Pinia (useQuery, useMutation, query keys, a Nuxt module) with a smaller surface. Use one when the app is a client-heavy dashboard with many interdependent queries; keep useAsyncData for page-level SSR data.
  • Server-side caching is a separate layer: Nitro defineCachedEventHandler, routeRules swr/isr, CDN Cache-Control and ETag. The fullstack section covers it; the client cache sits on top and must never cache personalised responses that the server also cached by URL.

How it works

Optimistic update with rollback and invalidation using TanStack Query in a Nuxt component:

app/components/Todo/Item.vue
<script setup lang="ts">
import { useMutation, useQueryClient } from '@tanstack/vue-query'

const props = defineProps<{ todo: { id: string, done: boolean } }>()
const client = useQueryClient()

const toggle = useMutation({
  mutationFn: (done: boolean) => $fetch(`/api/todos/${props.todo.id}`, { method: 'PATCH', body: { done } }),
  async onMutate(done) {
    await client.cancelQueries({ queryKey: ['todos'] }) // do not let an in-flight refetch overwrite the optimistic state
    const previous = client.getQueryData<typeof props.todo[]>(['todos'])
    client.setQueryData<typeof props.todo[]>(['todos'], old => old?.map(t => (t.id === props.todo.id ? { ...t, done } : t)))
    return { previous }
  },
  onError(_err, _vars, context) {
    if (context?.previous) client.setQueryData(['todos'], context.previous) // rollback
  },
  onSettled() {
    client.invalidateQueries({ queryKey: ['todos'] }) // reconcile with the server
  },
})
</script>

<template>
  <label>
    <input :checked="todo.done" type="checkbox" :disabled="toggle.isPending.value" @change="toggle.mutate(!todo.done)">
    {{ todo.id }}
  </label>
</template>

Wiring TanStack Query into SSR so the server's fetches hydrate the client cache:

app/plugins/vue-query.ts
import { dehydrate, hydrate, QueryClient, VueQueryPlugin, type DehydratedState } from '@tanstack/vue-query'

export default defineNuxtPlugin((nuxtApp) => {
  const state = useState<DehydratedState | null>('vue-query', () => null)
  const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 5_000 } } })
  nuxtApp.vueApp.use(VueQueryPlugin, { queryClient })

  if (import.meta.server) nuxtApp.hooks.hook('app:rendered', () => { state.value = dehydrate(queryClient) })
  if (import.meta.client) hydrate(queryClient, state.value)
})

Cursor pagination as an infinite query, keeping the previous pages while the next loads:

app/composables/useProductFeed.ts
import { useInfiniteQuery } from '@tanstack/vue-query'

export function useProductFeed(category: MaybeRefOrGetter<string>) {
  return useInfiniteQuery({
    queryKey: ['products', 'feed', { category }],
    queryFn: ({ pageParam, signal }) => $fetch<{ items: Product[], nextCursor: string | null }>('/api/products', {
      query: { category: toValue(category), after: pageParam },
      signal, // cancelled when the key changes or the component unmounts
    }),
    initialPageParam: null as string | null,
    getNextPageParam: last => last.nextCursor,
    staleTime: 60_000,
  })
}

The same idea with Nuxt's built-in layer when a full query library is not warranted: a mutation followed by targeted invalidation.

await $fetch(`/api/todos/${id}`, { method: 'PATCH', body: { done } })
await refreshNuxtData(['todos', `todo:${id}`]) // refetch only the affected keys
Gotcha· Invalidating everything

refreshNuxtData() or invalidateQueries() with no key after every mutation is the cache equivalent of restarting the server: it works, and it makes every list flicker and every API endpoint hot. Invalidate the keys the mutation could have changed, and structure keys hierarchically (['products'] → ['products', id]) so a prefix match covers them.

Gotcha· Optimistic updates on non-idempotent actions

Marking a payment as "done" optimistically and rolling back on failure shows the user a success that never happened. Reserve optimism for reversible, high-success actions and show pending state for the rest.

Verify before the interview:

The current TanStack Query major for Vue and its recommended Nuxt SSR wiring, and Pinia Colada's status and Nuxt module name.

docs ↗

Exercise

Exercise
  • Render the same useFetch('/api/products') in three components on one page and count requests; then give two of them different transform options and read the console warning.
  • Implement the optimistic toggle above against a server route that fails 30% of the time; confirm the rollback is visible and the list reconciles.
  • Convert an offset-paginated list to cursor pagination with useInfiniteQuery; insert an item at the top server-side between two page loads and check for duplicates in both versions.

Be able to say

Be able to say· How do you handle server data on the client: store or cache?

"Cache. The server owns the data, so the client keeps a copy keyed by everything that determines the result, with a staleness policy and explicit invalidation after mutations. In Nuxt the built-in layer is useAsyncData and useFetch: keyed, deduplicated, SSR-hydrated, refetched when the key changes, with getCachedData for custom staleness. When an app is a client-heavy dashboard with dozens of interdependent queries, I add TanStack Query or Pinia Colada for stale-while-revalidate, retries, refetch on focus, mutation state with optimistic updates, infinite queries and devtools, and wire it into the payload so the server's fetches hydrate the client cache. A Pinia store is for client-owned state; putting API data in it is how lists go stale."

Be able to say· Explain optimistic updates and when they are inappropriate.

"An optimistic update writes the expected result into the cache before the server confirms it: I cancel in-flight refetches for that key, snapshot the previous value, apply the change, and on error restore the snapshot; on settle I invalidate the key so the cache reconciles with the server. It is right for reversible, high-success actions where latency would feel like lag: toggles, reordering, likes. It is wrong for actions whose false success misleads, such as payments or sending a message, where a pending state and a confirmed result are the honest interface."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.