The Unexpected Shared State in Vue Composables
You build a useCounter() composable, drop <Counter /> on the page twice, and click the first button. Both counters go up. This isn't a bug in Vue's Composition API. It's a fundamental misunderstanding of what a composable truly represents: a function that can be called multiple times, and by default, each call creates a new, independent instance of its internal state. The surprise arises from a gap in your mental model. You might expect each invocation to be isolated, like separate components. However, the reality is more nuanced, especially when considering the implications beyond a single browser tab.
Consider a simple useCounter function:
// useCounter.js
import { ref } from 'vue';
export function useCounter(initialValue = 0) {
const count = ref(initialValue);
function increment() {
count.value++;
}
return { count, increment };
}
When you use this in a template:
<template>
<div>
<Counter />
<Counter />
</div>
</template>
<script setup>
import Counter from './Counter.vue';
// No explicit import needed for useCounter if it's part of Counter.vue
</script>
<!-- Counter.vue -->
<script setup>
import { useCounter } from './useCounter';
const { count, increment } = useCounter(0);
</script>
<template>
<button @click="increment">{{ count }}</button>
</template>
Each time useCounter() is called within a component instance, it creates a new ref for count. This means the two <Counter /> components get their own independent state. The issue described in the excerpt—where both counters increment—happens not because of shared state by default, but because of how the composable is *used* and potentially how its internal state is managed or exposed. The article's premise highlights a common misconception: that simply calling a composable twice automatically means shared state. In reality, the default behavior is isolation.
The more significant concern arises when this shared state logic is applied server-side, perhaps within a framework like Nuxt. If a composable manages a shared resource or configuration that is intended to be a singleton across all requests, but is incorrectly implemented with per-request state, it can lead to data leaks or incorrect behavior between different users. Imagine a useSession() composable that incorrectly stores session data in a global ref instead of a request-scoped one. Clicking a button in one user's tab could inadvertently affect another user's session, a much costlier mistake than two counters on the same page.
Understanding Composable State Management
The core of the problem lies in how Vue's Composition API manages state. Composables are essentially functions that encapsulate reactive logic. When you call a composable function, it executes its logic within the current component's context. If the composable uses Vue's reactivity primitives like ref or reactive, it creates reactive state. Each call to the composable function, within a separate component instance or even multiple times within the same component instance if designed that way, will create its own set of reactive state unless explicitly designed otherwise.
The article's example of two counters incrementing together suggests a scenario where the composable might be imported and used in a way that its internal state is indeed shared. This could happen if:
- The composable itself imports and uses a
refdirectly from a shared module, rather than creating a new one internally. - The composable's state is managed by a global store or a singleton pattern that is not properly scoped.
Let's clarify the default behavior: if a composable creates its reactive state internally using ref() or reactive(), each invocation results in a distinct state. The example from the excerpt, where two counters increment together, implies a deviation from this default. This is where the 'trap' lies – developers might assume the default behavior is isolation, but a subtle implementation detail or a misunderstanding of module scope can lead to unintended state sharing.
The key takeaway is that a composable is a function. Like any function, its behavior depends on its implementation. If it creates its own local state, each call is independent. If it accesses shared, external state, then multiple calls will indeed interact with that same shared state.
Predicting State Sharing
To predict whether a composable will share state, you need to examine its source code. Look for how reactive state is initialized. If a composable defines a `ref` or `reactive` inside its own function scope, each call will create a new instance of that state. This is the most common and recommended pattern for composables that manage component-specific state.
// Example of a composable with ISOLATED state
import { ref } from 'vue';
export function useIsolatedCounter() {
const count = ref(0); // New ref created on each call
function increment() { count.value++; }
return { count, increment };
}
Conversely, if a composable imports a `ref` from a separate module or relies on a global variable, then all calls to that composable will indeed share the same state. This pattern is useful for global state management but requires careful consideration.
// Example of a composable with SHARED state (singleton pattern)
import { ref } from 'vue';
// This ref is defined at the module level, shared by all imports
const sharedCount = ref(0);
export function useSharedCounter() {
function increment() {
sharedCount.value++;
}
return { count: sharedCount, increment };
}
By reading the source, you can determine if the state is defined within the composable's function body (isolated) or outside it in the module scope (shared). This allows you to anticipate the behavior without running the application.
Designing for Deliberate State Sharing
When you intentionally need shared state, composables are an excellent tool. This is common for global application state, like user authentication status, theme settings, or a shopping cart. The key is to implement the shared state at the module level, outside the composable function itself.
Here's how to build a composable for deliberate sharing:
- Define the reactive state at the module level: This ensures a single instance of the state exists for the entire application.
- Export the state and functions that modify it: Make the state accessible and manageable.
- Create the composable function: This function will return the shared state and its associated methods.
For instance, a useAuth() composable might look like this:
// useAuth.js
import { ref, computed } from 'vue';
const isAuthenticated = ref(false);
const user = ref(null);
export function login(userData) {
isAuthenticated.value = true;
user.value = userData;
}
export function logout() {
isAuthenticated.value = false;
user.value = null;
}
export function useAuth() {
const loggedIn = computed(() => isAuthenticated.value);
return { loggedIn, user, login, logout };
}
In this example, isAuthenticated and user are module-level refs. Any component calling useAuth() will receive the same reactive references, ensuring consistent authentication state across the application. This pattern is powerful but requires discipline to avoid unintended side effects.
Cleaning Up Side Effects
Composables often involve side effects, such as fetching data, setting up event listeners, or managing timers. It's crucial to clean these up when the component using the composable is unmounted to prevent memory leaks and unexpected behavior. Vue's onUnmounted lifecycle hook is perfect for this.
Consider a composable that fetches data:
// useFetch.js
import { ref, onMounted, onUnmounted } from 'vue';
export function useFetch(url) {
const data = ref(null);
const error = ref(null);
let controller = null; // To store AbortController instance
const fetchData = async () => {
error.value = null;
try {
controller = new AbortController();
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error('Network response was not ok');
data.value = await response.json();
} catch (e) {
if (e.name !== 'AbortError') {
error.value = e;
}
}
};
onMounted(fetchData);
onUnmounted(() => {
if (controller) {
controller.abort(); // Abort ongoing fetch request
}
});
return { data, error };
}
Here, onUnmounted is used to abort any ongoing fetch request when the component is removed from the DOM. This prevents callbacks from running on unmounted components and frees up resources. Proper cleanup ensures that your composables are robust and don't contribute to application instability, especially in complex SPAs or long-running server processes.
The 'shared state trap' isn't about Vue itself being flawed, but about understanding the implications of function scope, module scope, and reactive state management. By carefully examining composable source code and implementing cleanup logic, developers can harness the power of shared state effectively while avoiding common pitfalls.
