Zombie State in Pinia: A Silent Bug You Might Already Have
Source: Dev.to
What Is Zombie State?
- State that remains after a page, user flow, completed form, or finished async request has finished.
- It can still:
- Prefill inputs
- Trigger validation
- Influence UI decisions
- Cause “random” bugs
Because Pinia stores are global, long‑lived singletons by default, navigation away does not reset store data. Unless you explicitly reset it, the state stays in memory even when it no longer makes sense.
A Very Common Example
// useWizardStore.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useWizardStore = defineStore('wizard', () => {
const step = ref(2)
const email = ref('old@email.com')
return { step, email }
})
- User opens the wizard.
- The wizard starts on step 2 and shows the old email.
- Validation fails unexpectedly.
The store never resets, creating zombie state.
Async‑Related Zombie State
async function loadUser() {
user.value = await fetchUser()
}
If the async response arrives after the component has been unmounted or the context has changed, the stale data overwrites the current state, producing the same problem.
Symptoms to Watch For
- Forms already filled when they shouldn’t be
- Validation errors on first render
- Wrong data after navigation (back/forward)
- UI behaving differently after navigation
- Bugs that disappear on page refresh
These bugs are hard to debug because they depend on navigation history rather than the current state.
Preventing Zombie State
1. Add an Explicit Reset
// useWizardStore.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useWizardStore = defineStore('wizard', () => {
const step = ref(1)
const email = ref('')
function reset() {
step.value = 1
email.value = ''
}
return { step, email, reset }
})
import { onUnmounted } from 'vue'
import { useWizardStore } from '@/stores/useWizardStore'
const store = useWizardStore()
onUnmounted(() => store.reset())
Pinia won’t reset state for you—you must be intentional.
2. Reset on Context Changes
import { watch } from 'vue'
import { useRoute } from 'vue-router'
import { useWizardStore } from '@/stores/useWizardStore'
const route = useRoute()
const store = useWizardStore()
watch(
() => route.params.id,
() => store.reset()
)
This prevents old data from leaking into new contexts.
3. Guard Async Requests
let requestId = 0
async function load() {
const id = ++requestId
const data = await fetchData()
if (id !== requestId) return // ignore stale response
state.value = data
}
Late responses are ignored instead of overwriting valid state.
4. Separate UI State from Domain State
- Good candidates for stores: user data, authentication info, shared domain entities.
- Bad candidates for stores: short‑lived UI state (e.g., modal visibility, temporary form fields). Keep those close to the component.
Types of Stale State
| Type | Meaning |
|---|---|
| Stale state | Old but still valid |
| Zombie state | Invalid but still active |
Conclusion
Zombie state is dangerous because it looks correct until it isn’t. When state outlives its owner, it becomes a zombie. The issue isn’t specific to Pinia; it’s a state‑lifecycle problem.
Pinia gives us powerful tools, but with that power comes responsibility:
- Define ownership
- Control lifetime
- Reset intentionally
Applying these practices leads to applications that are:
- Easier to reason about
- Easier to debug
- More predictable for users
If this helps even one developer avoid a hard‑to‑trace production bug, it’s worth sharing.