obsidian/wiki/concepts/pinia-storeftorefs-array-reactivity.md
2026-05-06 21:30:03 +01:00

4.8 KiB

title aliases tags sources created updated
Pinia storeToRefs — Array Reassignment Breaks Reactivity
pinia-storeftorefs-reactivity
pinia-array-assignment
pinia-stale-ref
pinia
vue
javascript
reactivity
frontend
gotcha
debugging
daily/2026-05-06.md
2026-05-06 2026-05-06

Pinia storeToRefs — Array Reassignment Breaks Reactivity

In Pinia Options API stores, replacing an array via direct assignment (this.timeline = newArray) makes the Ref returned by storeToRefs go stale. The component's destructured ref holds a reference to the old array object — it never sees the new array. The store itself has correct data; the component sees [].

Key Takeaways

  • this.timeline = data in a Pinia Options API action causes storeToRefs-derived refs to be orphaned — timeline.value stays empty while reportsStore.timeline has 7 items
  • The stale ref fires watchEffect exactly once (with the initial empty state) and never again — confirming the ref is orphaned, not the store
  • Fix: mutate in-place with this.timeline.splice(0, this.timeline.length, ...data) OR use this.$patch({ timeline: data })
  • Audit all Pinia Options API stores for this.x = newArray patterns — replace with splice or $patch
  • Related: Vue auth race condition — initAuth() called in App.vue onMounted races with child component onMounted fetches; fix: call initAuth() synchronously in main.ts before app.mount()

Details

The Stale Ref Pattern

// Pinia Options API store — BROKEN
export const useReportsStore = defineStore('reports', {
  state: () => ({
    timeline: [],
  }),
  actions: {
    async fetchTimeline(from, to) {
      const data = await reportsAPI.getTimeline(from, to);
      this.timeline = data;  // ← BREAKS storeToRefs reactivity
    },
  },
});
<!-- Component  storeToRefs ref becomes stale -->
<script setup>
const reportsStore = useReportsStore();
const { timeline } = storeToRefs(reportsStore);  // captures ref to original array

await reportsStore.fetchTimeline(from, to);
// reportsStore.timeline.length === 7  ✓ store has data
// timeline.value.length === 0         ✗ ref is stale — points to old []
</script>

The Fix

// Option A: Mutate in-place (preferred — no API change)
async fetchTimeline(from, to) {
  const data = await reportsAPI.getTimeline(from, to);
  this.timeline.splice(0, this.timeline.length, ...data);  // mutates, ref stays valid
},

// Option B: $patch (explicit, works in both Options and Setup API)
async fetchTimeline(from, to) {
  const data = await reportsAPI.getTimeline(from, to);
  this.$patch({ timeline: data });
},

Diagnosis Signals

When data.value.length === 0 but store.data.length > 0 after an await:

  1. watchEffect fires once then stops — stale ref, not reactive to store changes
  2. Identity checkstoreToRefs returns a ref to the original array object; assigning a new array breaks the link
  3. Direct store access works{{ reportsStore.timeline }} in template renders correctly; {{ timeline }} does not

Auth Initialization Race Condition

A related Vue bootstrap gotcha: if initAuth() (restoring token from localStorage) is called in App.vue's onMounted, it races with child components that also call onMounted and fire API requests. Both onMounted hooks run in the same microtask queue — child components may fetch before auth is set.

// ❌ BROKEN — App.vue onMounted races with child component onMounted
// App.vue
onMounted(() => {
  authStore.initAuth();  // may run AFTER DashboardView's onMounted
});

// DashboardView.vue
onMounted(async () => {
  await fetchData();  // fires with token=null if initAuth lost the race
});
// ✅ CORRECT — main.ts, synchronous before app.mount()
const app = createApp(App);
app.use(pinia);
app.use(router);

// Initialize auth synchronously BEFORE mount
const authStore = useAuthStore(pinia);
authStore.initAuth();  // reads from localStorage synchronously

app.mount('#app');  // all components now have token on first render

Sources

  • daily/2026-05-06.md — cc-dashboard: Dashboard charts empty despite API returning data; reportsStore.timeline.length === 7 but timeline.value.length === 0 after await; fixed by replacing this.timeline = data with this.timeline.splice(...); auth race condition fixed by moving initAuth() to main.ts before app.mount()