Server State Is Not App State

Fetched data is a cached copy of state you do not own, so it belongs in a query cache rather than in a store.

Syntaxconst { orders, status, error, refresh } = useOrders()

App state is yours. A cart, an open dialog, an unsaved draft: your code created the value and your code is the only thing that can change it. Server state is not yours. It is a copy of rows that live somewhere else and can change without your page being told.

A copy needs more than a value. It needs to know when it arrived, whether a request is in flight, whether the last attempt failed, and when it should be fetched again. That bundle is what a query cache keeps, keyed by the request. Two components asking for the same key share one entry and one in-flight request, and after a write you invalidate the key rather than patching the copy by hand.

What goes wrong in a store

  • No expiry. Once rows are in state they stay there until you write code to remove them, so a long-lived tab shows yesterday's prices.
  • Two truths after a write. The store holds one version of the order, the server another, and nothing reconciles them.
  • One loading flag for many requests, because per-key status was not designed in.
  • No deduplication. Two components mounting means two identical requests.

What does belong in a store

The questions, not the answers: the id being viewed, the current filters, the access token, a draft that has not been sent. Keep those, and let the cache own every row that came from the server.

Example

Example · vue
<script setup lang="ts">
import { useOrders } from '../composables/useOrders'

// No local copy of the rows. The cache owns them.
const { orders, status, error, refresh } = useOrders()
</script>

<template>
  <section>
    <header>
      <h2>Orders</h2>
      <button
        type="button"
        :disabled="status === 'loading'"
        @click="refresh"
      >
        Refresh
      </button>
    </header>

    <p v-if="status === 'loading' && orders.length === 0">Loading orders</p>
    <p v-else-if="status === 'error'">{{ error?.message }}</p>
    <ul v-else>
      <li v-for="order in orders" :key="order.id">
        {{ order.reference }} ({{ order.total }})
      </li>
    </ul>
  </section>
</template>

<style scoped>
header {
  display: flex;
  align-items: baseline;
  gap: 0.75rem;
}
</style>

When to use it

  • A dashboard where three widgets need the same orders list and should send one request between them.
  • Prices that must not still be yesterday's after a tab is left open overnight.
  • A cancel button whose list has to update without hand-editing an array.
  • Untangling a store that has slowly become a copy of the database.

More examples

The cache the component talks to

Everything the prose claims a cache does is visible here: entries keyed by request, one shared promise so simultaneous callers produce one request, a timestamp so a stale entry refetches on the next mount, and status and error per key rather than per application. The refs live in the module, outside any component, which is exactly the shared-forever behaviour you do not want for app state and exactly what you want for a cache. A library such as TanStack Query gives you this plus retries, garbage collection and refetch-on-focus; read this file to understand what such a library is doing for you.

Example · typescript
// src/composables/useOrders.ts
import { ref, type Ref } from 'vue'

export interface Order {
  id: string
  reference: string
  total: string
}

type Status = 'idle' | 'loading' | 'success' | 'error'

interface Entry {
  orders: Ref<Order[]>
  status: Ref<Status>
  error: Ref<Error | null>
  fetchedAt: number
  inFlight: Promise<void> | null
}

const cache = new Map<string, Entry>()

function entryFor(key: string): Entry {
  const existing = cache.get(key)
  if (existing !== undefined) return existing
  const created: Entry = {
    orders: ref<Order[]>([]),
    status: ref<Status>('idle'),
    error: ref<Error | null>(null),
    fetchedAt: 0,
    inFlight: null
  }
  cache.set(key, created)
  return created
}

function load(entry: Entry): Promise<void> {
  if (entry.inFlight !== null) return entry.inFlight
  entry.status.value = 'loading'
  entry.inFlight = fetch('/api/orders')
    .then(async (response) => {
      if (!response.ok) throw new Error(`Orders failed: ${response.status}`)
      entry.orders.value = (await response.json()) as Order[]
      entry.fetchedAt = Date.now()
      entry.status.value = 'success'
    })
    .catch((cause: unknown) => {
      entry.error.value = cause instanceof Error ? cause : new Error('Orders failed')
      entry.status.value = 'error'
    })
    .finally(() => {
      entry.inFlight = null
    })
  return entry.inFlight
}

export function invalidateOrders(key = 'orders'): Promise<void> {
  const entry = cache.get(key)
  if (entry === undefined) return Promise.resolve()
  entry.fetchedAt = 0
  return load(entry)
}

export function useOrders(key = 'orders', maxAgeMs = 30_000) {
  const entry = entryFor(key)
  if (Date.now() - entry.fetchedAt > maxAgeMs) void load(entry)
  return {
    orders: entry.orders,
    status: entry.status,
    error: entry.error,
    refresh: () => invalidateOrders(key)
  }
}

A write that invalidates instead of patching

CancelOrderButton.vue posts the cancellation, then throws the cached list away and lets it reload. It never looks for the order in an array and edits it, so there is no code that can disagree with the server about what changed. Every component showing orders updates from the one refetch. pending and failed are local: they describe this button's attempt, not the orders, so they do not belong in the cache.

Example · vue
<script setup lang="ts">
import { ref } from 'vue'
import { invalidateOrders } from '../composables/useOrders'

const props = defineProps<{ orderId: string }>()

const pending = ref(false)
const failed = ref(false)

async function cancel(): Promise<void> {
  pending.value = true
  failed.value = false
  try {
    const response = await fetch(`/api/orders/${props.orderId}/cancel`, {
      method: 'POST'
    })
    if (!response.ok) throw new Error(String(response.status))
    await invalidateOrders()
  } catch {
    failed.value = true
  } finally {
    pending.value = false
  }
}
</script>

<template>
  <div>
    <button type="button" :disabled="pending" @click="cancel">
      Cancel order
    </button>
    <p v-if="failed">Could not cancel that order. Try again.</p>
  </div>
</template>

The question in a store, the answer in the cache

This store holds no rows. It holds what the user asked for, and derives the key the cache will be looked up with, so changing a filter makes the component read a different entry rather than mutate the old one. Reach for this split when several distant components must agree on a filter; if only one page cares, the URL is the better home. Note the .value inside the store and its absence in a template, which unwraps refs for you.

Example · typescript
// src/stores/orderFilters.ts
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'

type OrderStatus = 'all' | 'open' | 'shipped'

export const useOrderFiltersStore = defineStore('orderFilters', () => {
  const status = ref<OrderStatus>('open')
  const page = ref(1)

  // The cache key is derived, never stored, so it cannot go out of date.
  const cacheKey = computed(() => `orders:${status.value}:${page.value}`)

  function setStatus(next: OrderStatus): void {
    status.value = next
    page.value = 1
  }

  return { status, page, cacheKey, setStatus }
})
// Output: with status 'open' and page 1, cacheKey is 'orders:open:1'
// Output: after setStatus('shipped'), cacheKey is 'orders:shipped:1'

Discussion

  • Be the first to comment on this lesson.