Choosing a Home for State

Local, props, provide/inject, a Pinia store or a server cache: a table for choosing, with a worked example of each.

Vue offers several homes for a value, and picking well is most of what keeps an application readable a year later. The choice is about reach: who is allowed to see this value, and who is allowed to change it.

HomeWho can read itReach for it whenCost
ref in the componentthat one instanceone component owns the valuenone
props and emitsa child, from its parenta parent and its child share a valueevery level in between forwards it
provide / injectany descendant of the providera whole subtree needs it, such as a form or a layoutthe dependency is invisible at the call site
a Pinia storeany component in the appit outlives the route and has no server behind itanything can write it
a server cacheanything asking for the same keythe value really belongs to the serverfreshness becomes your problem

How to choose

Start in the component. Move one step out when a real second reader appears, not when you imagine one. A sidebar's collapsed flag is local. A table's filter usually belongs in the URL, so a reload keeps it. What genuinely belongs in a store is the session, a cart, or a queue of unsaved edits.

Trips people up

provide is not a store: each provider creates a separate value for its own subtree. And a store cannot be destructured. const { user } = useSessionStore() copies the value and stops updating; use storeToRefs for state and getters. Actions destructure safely, because they are bound to the store.

Example

Example · vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useSessionStore } from '../stores/session'

// compact is the parent's decision, so it is a prop.
const props = defineProps<{ compact?: boolean }>()

// The signed-in user outlives this component and every route, so it is a store.
const session = useSessionStore()

// Nothing else cares whether the menu is open, so it stays local.
const open = ref(false)

const label = computed(() => {
  const name = session.user?.name
  if (name === undefined) return 'Sign in'
  return props.compact ? name.slice(0, 1) : name
})
</script>

<template>
  <div class="menu">
    <button type="button" @click="open = !open">{{ label }}</button>
    <ul v-if="open">
      <li><router-link to="/account">Account</router-link></li>
      <li v-if="session.isAdmin">
        <router-link to="/admin">Admin</router-link>
      </li>
      <li>
        <button type="button" @click="session.signOut()">Sign out</button>
      </li>
    </ul>
  </div>
</template>

<style scoped>
.menu {
  position: relative;
}
</style>

When to use it

  • Deciding where a cart lives when both the header and the checkout page read it.
  • Giving every input in a form the same disabled flag without adding a prop at each level.
  • Working out whether a sidebar's collapsed flag deserves to be global.
  • Reviewing a store that has quietly become the home for everything.

More examples

provide/inject with a typed key, wrapped in two functions

A fieldset component calls provideFieldSet({ disabled }) and every input beneath it calls useFieldSet(), however deep it sits, with no prop on any level in between. Wrapping provide and inject in two exported functions keeps the key private and gives both sides the same type, since inject<T>(key) returns T | undefined when no default is passed. Reach for this when a subtree shares context, not when the whole application does.

Example · typescript
// src/composables/useFieldSet.ts
import { inject, provide, type InjectionKey, type Ref } from 'vue'

export interface FieldSetContext {
  disabled: Readonly<Ref<boolean>>
}

// A Symbol key cannot collide with another feature's string key.
const fieldSetKey = Symbol('fieldSet') as InjectionKey<FieldSetContext>

export function provideFieldSet(context: FieldSetContext): void {
  provide(fieldSetKey, context)
}

export function useFieldSet(): FieldSetContext | undefined {
  return inject(fieldSetKey)
}
// Output: useFieldSet() is undefined in a component with no FieldSet above it

A Pinia setup store for state that outlives the route

This is a setup store: the second argument is a function, refs become state, computed values become getters and functions become actions, and it must return every one of them or Pinia will not see it. The session earns a store because it is read by the header, the router guards and half the pages, and it has no single owning component. Note the .value inside the store and the absence of it in the component that consumes the store, where the template unwraps refs for you.

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

export interface User {
  id: string
  name: string
  roles: string[]
}

export const useSessionStore = defineStore('session', () => {
  const user = ref<User | null>(null)

  const isAdmin = computed(() => user.value?.roles.includes('admin') ?? false)

  function signIn(next: User): void {
    user.value = next
  }

  function signOut(): void {
    user.value = null
  }

  return { user, isAdmin, signIn, signOut }
})
// Output: while user is null, isAdmin is false

The same value in the wrong home, and the move

StatusFilter.vue renders a two-option select; choosing one rewrites the query string, and reloading the page keeps the choice. A store would have held the same value with more reach and no shareable link. Use the table above in this direction: if a value answers what am I looking at, prefer the URL over a store, and keep the store for what the URL cannot carry.

Example · vue
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'

// This started as a store: filters.status, written from four components.
// It is a question about what the page shows, so the URL owns it instead.
const route = useRoute()
const router = useRouter()

const status = computed<string>({
  get: () => (route.query.status === 'closed' ? 'closed' : 'open'),
  set: (value) => {
    void router.replace({
      path: route.path,
      query: { ...route.query, status: value }
    })
  }
})
</script>

<template>
  <select v-model="status">
    <option value="open">Open</option>
    <option value="closed">Closed</option>
  </select>
</template>

<style scoped>
select {
  padding: 0.25rem;
}
</style>

Discussion

  • Be the first to comment on this lesson.