
Vue gives you several ways to share state and logic across components: composables, provide/inject, and Pinia. Each solves a different problem, and picking the wrong one leads to awkward workarounds or unnecessary complexity. This guide breaks down what each tool does, when it shines, and when to reach for something else.
Now let's unpack each one.
A composable is a function that uses Vue's Composition API to encapsulate and reuse stateful logic. It's just a function that calls ref, computed, watch, or other composables internally and returns reactive state and methods.
import { ref, onMounted, onUnmounted } from "vue";
export function useMouse() {
const x = ref(0);
const y = ref(0);
function update(event: MouseEvent) {
x.value = event.pageX;
y.value = event.pageY;
}
onMounted(() => window.addEventListener("mousemove", update));
onUnmounted(() => window.removeEventListener("mousemove", update));
return { x, y };
}<script setup lang="ts">
import { useMouse } from "./composables/useMouse";
const { x, y } = useMouse();
</script>
<template>
<p>Mouse position: {{ x }}, {{ y }}</p>
</template>Each component that calls useMouse() gets its own independent copy of the state. Component A's mouse position doesn't affect component B's.
useCounter() in two components creates two separate counters. If you need them to share the same count, a composable alone won't do it (see the "Shared State Composable" pattern below).You can share state through composables by lifting the reactive state outside the function:
import { ref, computed } from "vue";
// 👇 this ref is now shared between all components calling useSharedCounter()
// because it's declared outside the useSharedCounter function
const count = ref(0);
export function useSharedCounter() {
const doubled = computed(() => count.value * 2);
function increment() {
count.value++;
}
return { count, doubled, increment };
}Now every component calling useSharedCounter() reads and writes the same count. This works for simple cases, but keep the tradeoffs in mind:
For non-trivial shared state, Pinia is almost always the better choice.
provide and inject let a parent component supply values to any descendant, no matter how deeply nested, without passing props through every intermediate component.
<!-- GrandParent.vue -->
<script setup lang="ts">
import { provide, ref } from "vue";
const theme = ref("dark");
provide("theme", theme);
</script>
<template>
<Parent />
</template><!-- DeepChild.vue (nested several levels down) -->
<script setup lang="ts">
import { inject } from "vue";
const theme = inject("theme");
</script>
<template>
<div :class="theme">Themed content</div>
</template>The Parent component doesn't need to know about theme at all. The value flows directly from GrandParent to DeepChild.
<Tabs> / <Tab>, <Accordion> / <AccordionItem>, or <Form> / <FormField> use provide/inject to coordinate without forcing the consumer to wire everything manually.A classic use case is a tabs component where the parent <Tabs> manages which tab is active and individual <Tab> components register themselves and check if they're the active one.
<!-- Tabs.vue -->
<script setup lang="ts">
import { provide, ref } from "vue";
const activeTab = ref("");
const tabs = ref<{ id: string; label: string }[]>([]);
function setActiveTab(id: string) {
activeTab.value = id;
}
provide("tabs", {
activeTab,
registerTab(id: string, label: string) {
const alreadyRegistered = tabs.value.find((t) => t.id === id);
if (!alreadyRegistered) {
tabs.value.push({ id, label });
}
},
});
</script>
<template>
<div>
<button v-for="tab in tabs" @click="setActiveTab(tab.id)">
{{ tab.label }}
</button>
</div>
<div class="tabs">
<slot></slot>
</div>
</template><!-- Tab.vue -->
<script setup lang="ts">
import { inject, computed } from "vue";
const props = defineProps<{ id: string; label: string }>();
const tabs = inject("tabs")!;
tabs.registerTab(props.id, props.label);
const isActive = computed(() => tabs.activeTab.value === props.id);
</script>
<template>
<div v-show="isActive">
<slot></slot>
</div>
</template>The consumer just writes:
<Tabs>
<Tab id="overview" label="Overview">
<p>Overview content</p>
</Tab>
<Tab id="settings" label="Settings">
<p>Settings content</p>
</Tab>
</Tabs>No wiring, no prop threading. The <Tab> components find their parent <Tabs> context automatically. See a working example of this on StackBlitz.
provide in App.vue, it is globally available, but you lose devtools support, hot-module replacement of state, and the structure that Pinia gives you. For anything truly global, use Pinia.One common complaint about provide/inject is that inject returns unknown by default, and using string keys is fragile. Fix both with InjectionKey:
import type { InjectionKey, Ref } from "vue";
export interface TabsContext {
activeTab: Ref<string>;
registerTab: (id: string, label: string) => void;
}
export const TabsKey: InjectionKey<TabsContext> = Symbol("tabs");Now provide(TabsKey, ...) and inject(TabsKey) are fully typed.
Pinia is Vue's official state management library. It lets you define stores—containers for state, getters, and actions—that live outside the component tree and are accessible from anywhere in your app.
import { defineStore } from "pinia";
import { ref, computed } from "vue";
export const useAuthStore = defineStore("auth", () => {
const user = ref<User | null>(null);
const isLoggedIn = computed(() => user.value !== null);
async function login(credentials: Credentials) {
user.value = await api.login(credentials);
}
function logout() {
user.value = null;
}
return { user, isLoggedIn, login, logout };
});<script setup lang="ts">
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
</script>
<template>
<div v-if="auth.isLoggedIn">
Welcome, {{ auth.user.name }}
<button @click="auth.logout()">Log out</button>
</div>
<LoginForm v-else @submit="auth.login" />
</template>Every component that calls useAuthStore() gets the same store instance. Change the user in one place, and every component sees the update.
ref() directly.Here's a side-by-side comparison to make the differences concrete:
| Composables | Provide/Inject | Pinia | |
|---|---|---|---|
| Primary purpose | Reuse logic | Pass data down a subtree | Global shared state |
| Scope | Per call (default) | Component subtree | Entire application |
| State sharing | Separate per call* | Shared within subtree | Shared globally |
| DevTools support | No | No | Yes |
| SSR safe | Depends on usage | Yes | Yes |
| Getters / computed | Manual | Manual | Built-in |
| Actions | Return functions | Manual | Built-in |
| Best for | Behavior extraction | Avoiding prop drilling | App-wide state |
* Unless you use the shared state composable pattern (module-level refs).
When you're staring at a piece of state and wondering where it should live, ask these questions in order:

Does only one component use this state?
→ Yes: Use a local ref or reactive. No abstraction needed.
Am I extracting reusable logic (not shared state)?
→ Yes: Use a composable. Each consumer gets its own instance.
Do I need to pass data down a component subtree without prop drilling?
→ Yes: Use provide/inject. Especially for tightly coupled component groups.
Do multiple unrelated parts of the app need to read or write this state?
→ Yes: Use Pinia. It's built for global, shared, inspectable state.
These tools compose together. In fact, the best Vue apps use all three in appropriate places.
A composable can use a Pinia store internally:
import { useAuthStore } from "@/stores/auth";
import { computed } from "vue";
export function usePermissions() {
const auth = useAuthStore();
const canEdit = computed(
() => auth.user?.role === "admin" || auth.user?.role === "editor",
);
const canDelete = computed(() => auth.user?.role === "admin");
return { canEdit, canDelete };
}A provide/inject setup can provide a Pinia store (or a composable's return value) to a subtree:
<script setup lang="ts">
import { provide } from "vue";
import { useFormValidation } from "@/composables/useFormValidation";
const validation = useFormValidation({ rules: formRules });
provide("form-validation", validation);
</script>The goal isn't to pick one tool. It's to pick the right tool for each job.
Not every piece of state needs a store. A modal's open/closed state, a text input's value, a local loading flag — these are all fine as local refs. Overusing Pinia clutters your stores and makes simple components depend on global state unnecessarily.
Providing everything from App.vue technically works, but you lose devtools integration, plugin support, and the organizational structure that Pinia gives you. If the state is truly global, use Pinia.
The module-level ref pattern is fine for simple apps. But once you need multiple pieces of related state, derived values, async actions, and debugging — you're building a store from scratch. Just use Pinia, your teammates will thank you.
Module-level state (including shared state composables) is dangerous in SSR because the module is shared across all requests on the server. One user's state can leak to another. Pinia handles this correctly out of the box by scoping state to the request.
The best Vue apps don't pick one tool — they use all three where they fit. Composables for reusable logic. Provide/inject for subtree coordination. Pinia for global state that needs devtools and SSR safety.
When in doubt, start with a ref. You can always promote it later. The real mistake isn't picking the wrong abstraction on the first try — it's reaching for a complex one before you need it.
To go deeper, check out our courses on the Vue.js Composition API, Pinia, and Vue Component Design.



Our goal is to be the number one source of Vue.js knowledge for all skill levels. We offer the knowledge of our industry leaders through awesome video courses for a ridiculously low price.
More than 200.000 users have already joined us. You are welcome too!
© All rights reserved. Made with ❤️ by BitterBrains, Inc.