4.8 KiB
4.8 KiB
| title | aliases | tags | sources | created | updated | |||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Pinia storeToRefs — Array Reassignment Breaks Reactivity |
|
|
|
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 = datain a Pinia Options API action causesstoreToRefs-derived refs to be orphaned —timeline.valuestays empty whilereportsStore.timelinehas 7 items- The stale ref fires
watchEffectexactly 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 usethis.$patch({ timeline: data }) - Audit all Pinia Options API stores for
this.x = newArraypatterns — replace with splice or $patch - Related: Vue auth race condition —
initAuth()called inApp.vue onMountedraces with child componentonMountedfetches; fix: callinitAuth()synchronously inmain.tsbeforeapp.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:
watchEffectfires once then stops — stale ref, not reactive to store changes- Identity check —
storeToRefsreturns a ref to the original array object; assigning a new array breaks the link - 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
Related Concepts
- wiki/concepts/zustand-async-hydration — same class of "state not ready on first render" bug in React/Zustand
- wiki/concepts/react-useref-event-handler-state — React's async
useStaterequiringuseReffor synchronous access - wiki/concepts/websocket-react-token-guard — similar auth-before-connect pattern
Sources
- daily/2026-05-06.md — cc-dashboard: Dashboard charts empty despite API returning data;
reportsStore.timeline.length === 7buttimeline.value.length === 0after await; fixed by replacingthis.timeline = datawiththis.timeline.splice(...); auth race condition fixed by movinginitAuth()tomain.tsbeforeapp.mount()