Skip to content

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

FixDevs ·

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 (using Vue’s reactive()), but reactivity is lost when values are extracted incorrectly:

  • Plain destructuring breaks reactivityconst { name } = store extracts the current string value, not a reactive reference. The variable name won’t update when the store changes.
  • Wrapping the store in reactive() — Pinia stores are already reactive. Wrapping with reactive() or nesting inside another reactive() can break Pinia’s internal reactivity tracking.
  • Using computed() incorrectly — accessing store state inside a computed() works correctly, but setting it without an action bypasses the store’s reactivity system.
  • SSR mismatches — server-rendered state may not hydrate correctly on the client, causing the store to have stale or undefined values.
  • Composition API store with ref vs plain values — in the Setup store syntax, you must return ref()s and computed()s to keep them reactive; plain values are not reactive.

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.

For related Vue issues, see Fix: Vue v-model Not Working on Custom Components and Fix: Vue Computed Property 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