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.
| Home | Who can read it | Reach for it when | Cost |
|---|---|---|---|
ref in the component | that one instance | one component owns the value | none |
| props and emits | a child, from its parent | a parent and its child share a value | every level in between forwards it |
provide / inject | any descendant of the provider | a whole subtree needs it, such as a form or a layout | the dependency is invisible at the call site |
| a Pinia store | any component in the app | it outlives the route and has no server behind it | anything can write it |
| a server cache | anything asking for the same key | the value really belongs to the server | freshness 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
<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.
// 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 itA 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.
// 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 falseThe 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.
<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