Skip to content

Fix: Pinia State Not Reactive — Store Changes Not Updating the Component

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix Pinia store state not updating components — storeToRefs for destructuring, $patch for partial updates, avoiding reactive() wrapping, getters vs computed, and SSR hydration.

The Problem

A Pinia store state change doesn’t re-render the component:

<script setup>
import { useUserStore } from '@/stores/user';

const store = useUserStore();
const { name } = store;  // Destructured — loses reactivity
</script>

<template>
  <p>{{ name }}</p>  <!-- Shows initial value, never updates -->
</template>

Or a store action updates state but the template doesn’t reflect it:

// store
export const useCartStore = defineStore('cart', {
  state: () => ({ items: [] }),
  actions: {
    addItem(item) {
      this.items.push(item);  // Pushes to array — is this reactive?
    }
  }
});

Or a getter returns stale data after state changes:

getters: {
  totalPrice: (state) => {
    return state.items.reduce((sum, item) => sum + item.price, 0);
    // Returns 0 even though items have been added
  }
}

Why This Happens

Pinia state is reactive under the hood (Pinia wraps state in Vue’s reactive() proxy), but reactivity is lost the moment a value is extracted from the proxy as a plain JavaScript value. const { name } = store reads store.name once and binds the current string to a new local variable. That local variable has no connection to the proxy — when the store updates, the local variable still holds the old value.

This becomes especially confusing because Pinia’s docs and examples often show direct access (store.name) which always works, while destructuring breaks silently. There is no warning, no error, and no DevTools indicator that reactivity is gone. The template renders with the initial value and never updates.

Composition (Setup) stores add another twist. Inside a Setup store, every piece of state must be wrapped in ref() or reactive() — plain variables (let count = 0) are not reactive. Forgetting to wrap a value, or forgetting to include it in the store’s returned object, makes that piece of state invisible to consumers. The opposite mistake — wrapping the entire returned store in reactive() from the consumer side — breaks Pinia’s internal proxy chain and produces unpredictable behavior.

Other causes:

  • Mutating arrays via methods that return the same reference.sort() mutates in place and returns the same array reference. Vue’s reactivity may not detect the change inside a getter that returns the sorted array.
  • SSR hydration mismatch — state set on the server doesn’t always hydrate cleanly on the client, especially if the store is accessed before app.use(pinia) runs.
  • Multiple Pinia instances — in test environments or micro-frontend setups, two Pinia instances can coexist. Stores from instance A appear in instance B as separate, non-reactive copies.

In Production: Incident Lens

A non-reactive Pinia store in production produces UI that displays stale data — old prices on a product card, an out-of-date cart count badge, a profile name that doesn’t update after the user edits it. The component continues to function (no crashes, no errors), but it shows information that no longer matches the store’s actual state. Users see this as “the site is broken” or “my changes didn’t save,” even though the data has been correctly updated server-side.

How it surfaces: A user edits their profile and the new name is saved to the backend, but the header still shows the old name until the user refreshes. A cart shows zero items even after the user added something. A “Buy Now” button shows the wrong price because the discount applied by an action didn’t propagate. Bug reports come in as “data is wrong” rather than “site is down,” making the issue easy to dismiss as a one-off until enough reports accumulate.

Blast radius: Per-store and per-consumer. The breakage is scoped to components that destructured the store without storeToRefs or accessed state via a stale closure. Other components reading the same store via direct property access (store.name) work fine, which makes the bug hard to reproduce — it depends on which component the user sees.

Monitoring signals:

  • User-reported data staleness via support tickets or feedback widgets
  • Client-side error logs are usually empty (this bug doesn’t throw)
  • A/B test conversion rates dropping in cohorts that hit the affected component
  • Network traffic showing successful PATCH/POST responses that aren’t reflected in the UI (compare server response payloads against rendered DOM via Sentry session replay)

Recovery sequence: Immediate mitigation is a code fix — switch destructuring to storeToRefs() or access state via store.* directly. There is no runtime toggle. For an emergency, deploy a client-side patch that calls location.reload() after critical mutations to force a fresh render (a poor experience, but better than wrong data). The real fix is a code change followed by a deploy.

Postmortem preventives: Add an ESLint rule that flags destructuring directly from useXxxStore() return values. Write component tests that assert the template re-renders after a store mutation. Educate the team that storeToRefs is the default destructuring path for stores. Consider a lint rule banning const { ... } = useStore() patterns entirely.

Fix 1: Use storeToRefs to Destructure Reactively

The most common fix — use storeToRefs() to extract reactive refs from the store:

<script setup>
import { useUserStore } from '@/stores/user';
import { storeToRefs } from 'pinia';

const store = useUserStore();

// WRONG — plain destructuring loses reactivity
const { name, email } = store;

// CORRECT — storeToRefs preserves reactivity
const { name, email, isLoggedIn } = storeToRefs(store);
// name, email, isLoggedIn are Refs — they update when the store changes

// Actions don't need storeToRefs — they're functions, not reactive
const { login, logout, updateProfile } = store;
</script>

<template>
  <!-- These work correctly — name and email are reactive refs -->
  <p>{{ name }}</p>
  <p>{{ email }}</p>
  <button @click="logout">Logout</button>
</template>

Alternative — access state directly through the store (always reactive):

<script setup>
import { useUserStore } from '@/stores/user';

const store = useUserStore();
// Don't destructure — access through store
</script>

<template>
  <!-- Always reactive — reads from the store on each render -->
  <p>{{ store.name }}</p>
  <p>{{ store.email }}</p>
</template>

computed() for derived state:

<script setup>
import { computed } from 'vue';
import { useUserStore } from '@/stores/user';

const store = useUserStore();

// Derived state from store — correctly reactive
const displayName = computed(() =>
  store.name || store.email || 'Anonymous'
);
</script>

Fix 2: Update State Correctly in Actions

Pinia state mutations inside actions work with both direct assignment and $patch:

import { defineStore } from 'pinia';

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [] as CartItem[],
    discount: 0,
    loading: false,
  }),

  actions: {
    // CORRECT — direct mutation inside an action
    addItem(item: CartItem) {
      this.items.push(item);     // Array mutation IS reactive within actions
      // This works because Pinia wraps state in reactive()
    },

    removeItem(id: string) {
      this.items = this.items.filter(item => item.id !== id);
      // Or: mutate in place
      const index = this.items.findIndex(item => item.id === id);
      if (index !== -1) this.items.splice(index, 1);
    },

    // Async actions
    async fetchCart() {
      this.loading = true;
      try {
        const data = await api.getCart();
        this.items = data.items;      // Replace the array — reactive
        this.discount = data.discount;
      } finally {
        this.loading = false;
      }
    },

    // $patch for multiple state changes atomically
    applyPromoCode(code: string) {
      this.$patch({
        discount: 0.15,
        promoCode: code,
        promoApplied: true,
      });
      // One reactive update instead of three
    },

    // $patch with function (useful for complex updates)
    addMultipleItems(newItems: CartItem[]) {
      this.$patch((state) => {
        state.items.push(...newItems);
        state.lastUpdated = new Date();
      });
    },
  },
});

Mutating state directly outside actions (only in non-strict mode):

const store = useCartStore();

// Works in non-strict mode (default), but prefer using actions
store.discount = 0.1;

// $patch is always allowed (no strict mode restriction)
store.$patch({ discount: 0.1 });

// Enable strict mode to prevent direct state mutations:
// In Pinia setup: createPinia() or in nuxt config
// pinia.use(({ store }) => { store.$state = readonly(store.$state) })

Fix 3: Fix Setup Store (Composition API) Reactivity

The Setup store syntax uses Vue’s Composition API directly. You must return ref()s and computed()s — plain values won’t be reactive:

import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useCounterStore = defineStore('counter', () => {
  // State — must use ref() or reactive()
  const count = ref(0);
  const history = ref<number[]>([]);

  // WRONG — plain variable is not reactive
  // let label = 'Counter';  // Not reactive — changes don't update components

  // CORRECT — ref for primitives
  const label = ref('Counter');

  // Getters — use computed()
  const doubled = computed(() => count.value * 2);
  const historyLength = computed(() => history.value.length);

  // Actions — plain functions
  function increment() {
    count.value++;
    history.value.push(count.value);
  }

  function reset() {
    count.value = 0;
    history.value = [];
  }

  // MUST return all state, getters, and actions
  return {
    count,
    history,
    label,
    doubled,
    historyLength,
    increment,
    reset,
  };
  // Anything NOT returned is private to the store
});

storeToRefs works the same with Setup stores:

<script setup>
import { useCounterStore } from '@/stores/counter';
import { storeToRefs } from 'pinia';

const store = useCounterStore();
const { count, doubled, label } = storeToRefs(store);
const { increment, reset } = store;
</script>

Fix 4: Fix Getters Not Updating

Getters (Options API) and computed() (Setup API) are cached and re-evaluate when their reactive dependencies change:

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [] as CartItem[],
  }),

  getters: {
    // CORRECT — reads this.items, which is reactive
    totalPrice(): number {
      return this.items.reduce((sum, item) => sum + item.price * item.qty, 0);
    },

    // CORRECT — getter using another getter
    formattedTotal(): string {
      return `$${this.totalPrice.toFixed(2)}`;
    },

    // CORRECT — getter with argument (returns a function)
    itemById: (state) => (id: string) => {
      return state.items.find(item => item.id === id);
    },
  },
});

// Usage — call with argument
const item = cartStore.itemById('product-42');

Getter not updating — common mistake:

getters: {
  // WRONG — caches the result, but the cached result is an array reference
  // Mutations to the array items (not the array itself) may seem to not update
  sortedItems: (state) => state.items.sort((a, b) => a.name.localeCompare(b.name)),
  // .sort() mutates state.items in-place AND returns the same array reference
  // Vue may not detect this as a change

  // CORRECT — create a new sorted array
  sortedItems: (state) => [...state.items].sort((a, b) => a.name.localeCompare(b.name)),
},

Fix 5: Reset Store State

Sometimes stale state is the issue — use $reset() to restore the initial state:

const store = useCartStore();

// Reset to initial state (Options API stores only)
store.$reset();

// Setup stores don't have $reset() by default — implement it manually
export const useCounterStore = defineStore('counter', () => {
  const count = ref(0);
  const initialState = { count: 0 };

  function $reset() {
    count.value = initialState.count;
  }

  return { count, $reset };
});

// Or use a plugin to add $reset to all Setup stores:
// https://pinia.vuejs.org/cookbook/composing-stores.html

Subscribe to store changes for debugging:

const store = useCartStore();

// Watch all state changes
store.$subscribe((mutation, state) => {
  console.log('Pinia mutation:', mutation.type);
  console.log('New state:', JSON.parse(JSON.stringify(state)));
  // mutation.type: 'direct' | 'patch object' | 'patch function'
});

// Watch specific state with Vue's watch
import { watch } from 'vue';
watch(
  () => store.items.length,
  (newLen, oldLen) => {
    console.log(`Cart items: ${oldLen} → ${newLen}`);
  }
);

Fix 6: Fix Pinia with Vue Router and SSR

In Nuxt or SSR setups, Pinia stores must be initialized after the Vue app is created:

// plugins/pinia-hydration.ts (Nuxt)
export default defineNuxtPlugin((nuxtApp) => {
  // Access the Pinia instance
  const pinia = nuxtApp.$pinia;

  // Hydrate from server state
  if (process.client && nuxtApp.payload.pinia) {
    pinia.state.value = nuxtApp.payload.pinia;
  }
});

Store not available during SSR — use useNuxtApp() or useStore() inside setup:

<!-- WRONG — store used at module level (before app is initialized) -->
<script>
const store = useUserStore();  // Error during SSR
</script>

<!-- CORRECT — store used inside setup() or <script setup> -->
<script setup>
const store = useUserStore();  // Called during component setup — safe
</script>

Pinia with Vue Router navigation guards:

// router/index.ts
import { createRouter } from 'vue-router';

const router = createRouter({ ... });

router.beforeEach(async (to) => {
  // Get the store INSIDE the guard, not outside
  const authStore = useAuthStore();  // Correct — accessed after app is set up

  if (to.meta.requiresAuth && !authStore.isLoggedIn) {
    return '/login';
  }
});

Fix 7: Debug Pinia Reactivity

Use Vue DevTools’ Pinia panel to inspect store state and track mutations:

// Add debugging to a store
export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [] as CartItem[],
  }),

  actions: {
    addItem(item: CartItem) {
      console.group('addItem action');
      console.log('Before:', JSON.parse(JSON.stringify(this.$state)));
      this.items.push(item);
      console.log('After:', JSON.parse(JSON.stringify(this.$state)));
      console.groupEnd();
    },
  },
});

// Subscribe to all actions globally (for logging)
const pinia = createPinia();
pinia.use(({ store }) => {
  store.$onAction(({ name, args, after, onError }) => {
    console.log(`[Pinia] Action: ${store.$id}.${name}`, args);
    after((result) => {
      console.log(`[Pinia] Action ${name} completed`, result);
    });
    onError((error) => {
      console.error(`[Pinia] Action ${name} failed`, error);
    });
  });
});

Still Not Working?

Pinia not installed as a pluginapp.use(pinia) must be called before any store is accessed. In Nuxt, this is handled automatically. In plain Vue 3, ensure the setup order:

import { createApp } from 'vue';
import { createPinia } from 'pinia';

const app = createApp(App);
const pinia = createPinia();

app.use(pinia);   // Must come before any store usage
app.use(router);
app.mount('#app');

Multiple Pinia instances — if you accidentally create multiple Pinia instances (e.g., one in the app, one in tests), stores from one instance aren’t reactive in the other. Ensure a single Pinia instance is shared across the app.

reactive() wrapping a store — don’t wrap a Pinia store in reactive(). Pinia stores are already reactive; double-wrapping can break the proxy chain.

Watching a destructured ref returned by storeToRefsstoreToRefs returns Ref objects. watch(name, ...) works because watch automatically unwraps refs. But watchEffect(() => console.log(name)) logs the ref object itself, not the value — use name.value inside the effect.

Pinia persisted state plugin overwriting fresh data — if you use pinia-plugin-persistedstate, the plugin restores state from localStorage on app load. If the schema changes (new fields added, types changed), the restored state may be incompatible with the new code, and components see stale or malformed data. Bump a version key in the persisted config and reset on mismatch.

HMR (Hot Module Replacement) breaking reactivity — Vite HMR can replace a store module without re-running consumers, leaving them holding references to the old store. If reactivity stops working only in dev after editing a store file, full-reload the page (Ctrl+R). Add import.meta.hot.accept(acceptHMRUpdate(useMyStore, import.meta.hot)) at the bottom of each store file to opt into proper HMR.

For related Vue issues, see Fix: Vue v-model Not Working on Custom Components, Fix: Vue Computed Property Not Updating, Fix: Vue Composable Not Reactive, and Fix: Vue Reactive Data Not Updating.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles