Fix: Vue Composable Not Reactive — ref and reactive Losing Reactivity After Destructuring
Part of: React & Frontend Errors
Quick Answer
How to fix Vue composable reactivity loss — toRefs for destructuring, returning refs vs raw values, reactive object pitfalls, stale closures, and composable design patterns.
The Problem
A Vue composable returns reactive data, but the component doesn’t update when it changes:
// useCounter.js
import { reactive } from 'vue';
export function useCounter() {
const state = reactive({ count: 0 });
function increment() {
state.count++;
}
return { count: state.count, increment };
// count is a plain number — NOT reactive
}
// Component
const { count, increment } = useCounter();
// count is 0 and stays 0 forever even when increment() is calledOr a composable using ref loses reactivity after destructuring:
// useUser.js
export function useUser() {
const user = ref(null);
const loading = ref(true);
async function fetchUser(id) {
user.value = await api.getUser(id);
loading.value = false;
}
return { user: user.value, loading: loading.value, fetchUser };
// Returning .value — plain values, not refs — not reactive
}Or the template stops updating after a composable function is called:
<template>
<p>Count: {{ count }}</p> <!-- Never updates -->
</template>
<script setup>
const { count } = useCounter(); // count is 0, permanently
</script>Why This Happens
Vue’s reactivity system tracks dependencies through reactive proxies (reactive()) and ref objects (ref()). When you extract a plain value from a reactive object, you lose the reactive connection:
reactive()spreading loses reactivity —const { count } = reactive({ count: 0 })gives you a plain number0, not a reactive reference. Changes to the reactive object don’t updatecount.ref.valueis the raw value — returningref.valuefrom a composable returns the plain JavaScript value at that moment. Returning therefitself (without.value) keeps reactivity.toRefs()converts reactive to refs —toRefs(state)converts each property of areactive()object into a separateref. These refs maintain their connection to the source.- Stale closures — a function in a composable that captures a reactive value at creation time (not reading from the reactive source each call) returns stale data.
In production, this bug is more dangerous than a typical render bug because it fails silently. The component renders successfully with the initial value, so there is no error, no warning in the console, and no obvious visual glitch on first load. Users see what looks like correct data. Then, when the underlying state changes — a new API response arrives, a websocket pushes an update, the user clicks a button that mutates state — the UI keeps showing the old value. Users are looking at stale data and don’t know it.
The blast radius is scoped to consumers of the broken composable. If useUser() is used in 12 components across the app, all 12 are affected. If useCart() is broken, users see stale cart totals and may submit orders based on incorrect prices. If useNotifications() is broken, urgent alerts never appear. The severity depends entirely on what the composable returns: an analytics dashboard showing stale numbers is bad; a checkout flow showing stale prices is a revenue incident. This kind of silent staleness rarely shows up in unit tests because tests typically check the initial render, not the update behavior across multiple state transitions.
Fix 1: Return refs Instead of Raw Values
The fundamental fix — return ref objects, not .value:
// WRONG — returning raw values
export function useCounter() {
const count = ref(0);
function increment() {
count.value++;
}
return {
count: count.value, // Plain number — NOT reactive
increment,
};
}
// CORRECT — return the ref itself
export function useCounter() {
const count = ref(0);
function increment() {
count.value++;
}
return {
count, // The ref object — reactive
increment,
};
}
// Component — use ref directly (template auto-unwraps)
const { count, increment } = useCounter();
// In template: {{ count }} ← auto-unwrapped, no .value needed
// In script: console.log(count.value)Composable using reactive() — use toRefs:
import { reactive, toRefs } from 'vue';
// WRONG — spreading reactive object loses reactivity
export function useForm() {
const state = reactive({
name: '',
email: '',
errors: {},
});
return { ...state }; // name, email, errors are plain values — not reactive
}
// CORRECT — use toRefs to convert reactive to refs
export function useForm() {
const state = reactive({
name: '',
email: '',
errors: {},
});
function validate() {
state.errors = {};
if (!state.name) state.errors.name = 'Required';
if (!state.email) state.errors.email = 'Required';
return Object.keys(state.errors).length === 0;
}
return {
...toRefs(state), // Each property becomes a linked ref
validate,
};
}
// Component
const { name, email, errors, validate } = useForm();
// name, email, errors are refs linked to state
// Updating name.value also updates state.nameFix 2: Use toRef for Single Properties
When you need only one property from a reactive object as a ref:
import { reactive, toRef } from 'vue';
export function useUser(initialData) {
const state = reactive({
user: initialData,
loading: false,
error: null,
});
// toRef creates a ref linked to a specific property
const user = toRef(state, 'user');
const loading = toRef(state, 'loading');
async function refresh(id) {
state.loading = true;
try {
state.user = await api.getUser(id);
} catch (e) {
state.error = e;
} finally {
state.loading = false;
}
}
return { user, loading, refresh };
}
// Or use toRefs for all properties
import { toRefs } from 'vue';
export function useUser(initialData) {
const state = reactive({ user: initialData, loading: false });
// ...
return { ...toRefs(state), refresh };
}Fix 3: Composable Design Patterns
Well-designed composables keep all state as refs and return them correctly:
// useAsync.js — generic async state composable
import { ref, computed } from 'vue';
export function useAsync(asyncFn) {
const data = ref(null);
const error = ref(null);
const loading = ref(false);
const isIdle = computed(() => !loading.value && !data.value && !error.value);
const isSuccess = computed(() => !loading.value && data.value !== null);
const isError = computed(() => !loading.value && error.value !== null);
async function execute(...args) {
loading.value = true;
error.value = null;
try {
data.value = await asyncFn(...args);
} catch (e) {
error.value = e;
} finally {
loading.value = false;
}
}
return {
data, // ref — reactive in template and script
error, // ref
loading, // ref
isIdle, // computed ref
isSuccess, // computed ref
isError, // computed ref
execute,
};
}
// Usage
const { data: users, loading, error, execute: loadUsers } = useAsync(
() => fetch('/api/users').then(r => r.json())
);
// Trigger the async call
loadUsers();Composable with reactive computed:
// useSearch.js
import { ref, computed, watch } from 'vue';
export function useSearch(items) {
const query = ref('');
const loading = ref(false);
// computed depends on query — auto-updates when query changes
const filteredItems = computed(() =>
items.value.filter(item =>
item.name.toLowerCase().includes(query.value.toLowerCase())
)
);
const resultCount = computed(() => filteredItems.value.length);
return { query, filteredItems, resultCount, loading };
}
// Component
const { query, filteredItems, resultCount } = useSearch(itemsRef);
// query.value = 'search term' → filteredItems auto-updatesFix 4: Reactive Props in Composables
Passing props to composables requires special handling — props are reactive objects, not plain values:
// WRONG — captures prop value at creation time (not reactive)
export function useDoubled(value) {
const doubled = computed(() => value * 2); // value is a number, not reactive
return { doubled };
}
// In component:
const props = defineProps({ count: Number });
const { doubled } = useDoubled(props.count); // props.count is 5 — doubled is 10
// When props.count changes to 10, doubled stays 10 (not 20)
// CORRECT — pass a getter function or ref
export function useDoubled(getValue) {
const doubled = computed(() => getValue() * 2); // Calls getter each time
return { doubled };
}
// Component — pass getter
const { doubled } = useDoubled(() => props.count);
// Now doubled updates when props.count changes
// OR — use toRef to convert prop to a linked ref
import { toRef } from 'vue';
const countRef = toRef(props, 'count'); // Ref linked to props.count
const { doubled } = useDoubled(countRef);
// In composable accepting a ref or getter:
export function useDoubled(source) {
const value = isRef(source) ? source : computed(source);
return { doubled: computed(() => value.value * 2) };
}Using MaybeRef type (Vue 3 utility type for composables):
// TypeScript — MaybeRef accepts both ref and plain value
import { ref, isRef, MaybeRef, computed } from 'vue';
export function useFormatted(value: MaybeRef<number>) {
const normalised = isRef(value) ? value : ref(value);
return {
formatted: computed(() => normalised.value.toFixed(2)),
};
}
// Works with both:
useFormatted(42); // Plain number
useFormatted(ref(42)); // Ref
useFormatted(props.count); // Prop value (not reactive as-is — better to pass toRef)
useFormatted(toRef(props, 'count')); // Reactive prop refFix 5: Shared State Across Components
For global state in composables, keep reactive objects at the module level:
// useGlobalCart.js — state shared across all components
import { ref, computed } from 'vue';
// Module-level state — shared singleton
const items = ref([]);
const isLoading = ref(false);
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.qty, 0)
);
export function useCart() {
function addItem(product) {
const existing = items.value.find(i => i.id === product.id);
if (existing) {
existing.qty++;
} else {
items.value.push({ ...product, qty: 1 });
}
}
function removeItem(id) {
items.value = items.value.filter(i => i.id !== id);
}
return { items, total, isLoading, addItem, removeItem };
}
// All components using useCart() share the same items, total, isLoading refsAvoid shared state for per-component composables:
// WRONG for per-component use — state shared across instances
const count = ref(0); // Module-level — shared
export function useCounter() {
return { count, increment: () => count.value++ };
}
// All Counter components share the same count
// CORRECT for per-component use — state inside the function
export function useCounter(initial = 0) {
const count = ref(initial); // Created fresh each call
return { count, increment: () => count.value++ };
}Fix 6: Watch Composable State
When a composable’s state needs to trigger side effects:
<script setup>
import { watch } from 'vue';
import { useUser } from './useUser';
const { user, loading } = useUser();
// Watch the ref — fires whenever user.value changes
watch(user, (newUser, oldUser) => {
if (newUser) {
document.title = `Profile: ${newUser.name}`;
}
});
// Watch multiple composable refs
watch([user, loading], ([newUser, newLoading]) => {
if (!newLoading && newUser) {
analytics.trackProfileView(newUser.id);
}
});
// Immediate watch — runs on mount and on changes
watch(user, (newUser) => {
// ...
}, { immediate: true });
</script>Fix 7: Debug Reactivity Loss
When you’re unsure if something is reactive:
import { isRef, isReactive, isReadonly } from 'vue';
// In composable or component
const result = useCounter();
console.log('count is ref:', isRef(result.count));
// true → reactive, false → plain value (loss of reactivity)
console.log('state is reactive:', isReactive(result.state));
// Log the raw value (bypasses reactive proxy)
import { toRaw } from 'vue';
console.log('Raw value:', toRaw(result.user));
// Track which properties are reactive
const { count, name } = useUser();
console.log({ countIsRef: isRef(count), nameIsRef: isRef(name) });Use Vue DevTools — the Vue DevTools browser extension shows reactive state in real time. Check whether your composable’s values appear in the component’s reactive state. If they don’t update in DevTools, they’re plain values.
Fix 8: Detect Stale-Data Incidents in Production
A reactivity-loss bug rarely throws an error, which is exactly what makes it dangerous. Users see what looks like a working UI but is showing data that no longer matches the underlying state. Catching this in production requires explicit monitoring.
Add reactivity assertions in development builds:
// composable-assertions.js
import { isRef } from 'vue';
export function assertReactive(values, composableName) {
if (process.env.NODE_ENV === 'production') return;
for (const [key, value] of Object.entries(values)) {
if (typeof value === 'function') continue;
if (!isRef(value)) {
console.error(
`[${composableName}] '${key}' is not a ref. ` +
`This likely means a consumer will see stale data.`
);
}
}
return values;
}
// In composables
export function useUser() {
const user = ref(null);
const loading = ref(false);
return assertReactive(
{ user, loading, refresh },
'useUser'
);
}Track unexpected staleness in production:
If a composable updates state in response to a server event (websocket message, polling response), instrument the consumer side to detect when the rendered value lags behind the actual data:
// In a critical component (e.g., checkout total)
import { watch } from 'vue';
import { useCart } from './useCart';
const { total } = useCart();
let lastSeenTotal = total.value;
let lastUpdateTime = Date.now();
watch(total, (newTotal) => {
const elapsed = Date.now() - lastUpdateTime;
if (elapsed > 5000 && newTotal !== lastSeenTotal) {
// Total changed but UI didn't update for 5+ seconds — possible staleness
analytics.track('cart_total_lag', { elapsed, oldValue: lastSeenTotal, newValue: newTotal });
}
lastSeenTotal = newTotal;
lastUpdateTime = Date.now();
});Real-world scenario: Imagine a trading dashboard composable useMarketData() that returns destructured raw values instead of refs. Users see prices that were correct when the page loaded but stop updating after the first WebSocket tick. They place trades based on stale prices. The bug is silent — no errors, no warnings. The only way to catch it is to monitor the gap between server-side updates and client-side renders, or to assert reactivity in development builds before the code ships.
Still Not Working?
reactive() with nested objects — nested objects inside reactive() are automatically made reactive. But replacing the entire nested object breaks the proxy: state.user = newUserObject works, but const { user } = state; user = newUserObject does not (you’re reassigning a local variable, not the reactive property).
shallowRef and shallowReactive — these create shallow reactivity. Only the top-level property changes are tracked. Deep object mutations don’t trigger updates. Use them only when deep reactivity isn’t needed.
Composable called outside setup() — composables must be called synchronously at the top level of setup() (or <script setup>). Calling them inside if statements, loops, or async callbacks may break Vue’s internal hook tracking.
Returning a Map or Set from reactive() — Vue 3 supports reactive Maps and Sets, but if you destructure values from them (e.g., const { size } = reactive(new Map())), you lose reactivity. Access them as myMap.size directly in the template or computed.
Destructuring after passing through a Pinia store — Pinia stores use storeToRefs() (not toRefs()) to convert state properties to refs. Using plain destructuring on a Pinia store loses reactivity in the same way as on a reactive() object.
TypeScript type narrowing breaking inference — if you type a composable return as { user: User | null } (plain values) when it actually returns { user: Ref<User | null> }, TypeScript may accept incorrect usage in consumers without errors. Explicitly type composable return values with Ref<T> to make reactivity loss surface as a type error.
For related Vue issues, see Fix: Vue Reactive Data Not Updating, Fix: Vue Computed Not Updating, Fix: React useEffect Missing Dependency, and Fix: Angular Change Detection Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Nuxt Not Working — useFetch Returns Undefined, Server Route 404, or Hydration Mismatch
How to fix Nuxt 3 issues — useFetch vs $fetch, server routes in server/api, composable SSR rules, useAsyncData, hydration errors, and Nitro configuration problems.
Fix: Vue Router Params Not Updating — Component Not Re-rendering or beforeRouteUpdate Not Firing
How to fix Vue Router params not updating when navigating between same-route paths — watch $route, beforeRouteUpdate, onBeforeRouteUpdate, and component reuse behavior explained.
Fix: Vue Slot Not Working — Named Slots Not Rendering or Scoped Slot Data Not Accessible
How to fix Vue 3 slot issues — v-slot syntax, named slots, scoped slots passing data, default slot content, fallback content, and dynamic slot names.
Fix: Vue Teleport Not Rendering — Content Not Appearing at Target Element
How to fix Vue Teleport not working — target element not found, SSR with Teleport, disabled prop, multiple Teleports to the same target, and timing issues.