Skip to content

Fix: Vue Composable Not Reactive — ref and reactive Losing Reactivity After Destructuring

FixDevs ·

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 called

Or 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 reactivityconst { count } = reactive({ count: 0 }) gives you a plain number 0, not a reactive reference. Changes to the reactive object don’t update count.
  • ref.value is the raw value — returning ref.value from a composable returns the plain JavaScript value at that moment. Returning the ref itself (without .value) keeps reactivity.
  • toRefs() converts reactive to refstoRefs(state) converts each property of a reactive() object into a separate ref. 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.name

Fix 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-updates

Fix 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 ref

Fix 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 refs

Avoid 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.

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