Fix: Vue Composable Not Reactive — ref and reactive Losing Reactivity After Destructuring
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.
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.
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.
For related Vue issues, see Fix: Vue Reactive Data Not Updating and Fix: Vue Computed Not Updating.
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.