Home / Blog / Composables vs. Provide/Inject vs. Pinia — When to Use What
Composables vs. Provide/Inject vs. Pinia — When to Use What

Composables vs. Provide/Inject vs. Pinia — When to Use What

Daniel Kelly
Daniel Kelly
Updated: March 3rd 2026

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.

The Quick Answer

  • Composables — Extract and reuse behavior. Each consumer gets its own state.
  • Provide/Inject — Pass state down a component subtree without prop drilling.
  • Pinia — Global application state that lives outside the component tree.

Now let's unpack each one.

Composables

What They Are

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.

When to Use Composables

  • Extracting reusable logic — Debouncing, fetching data, tracking scroll position, managing form validation, handling timers. Any behavior you'd otherwise duplicate across components.
  • Encapsulating complexity — A component is getting long. Pull related state + logic into a composable to keep the component readable.
  • Each consumer needs its own state — Every component calling the composable gets a fresh instance. This is exactly what you want for things like form state, local loading indicators, or per-component counters.

When NOT to Use Composables

  • You need shared state between components — Calling 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 need global app state — Authentication, user preferences, shopping carts. These belong in Pinia.

The Shared State Composable Pattern

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:

  • There's no devtools integration for inspecting or time-traveling state changes.
  • State lives in module scope, which can cause issues with SSR (state bleeds between requests) unless you're careful.
  • As complexity grows, you'll likely end up reinventing what Pinia already gives you.

For non-trivial shared state, Pinia is almost always the better choice.

Vue's Provide/Inject

What It Is

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.

When to Use Provide/Inject

  • Avoiding prop drilling — Deeply nested components need ancestor data, and threading props through every layer clutters intermediate components.
  • Component library internalsTightly coupled component groups like <Tabs> / <Tab>, <Accordion> / <AccordionItem>, or <Form> / <FormField> use provide/inject to coordinate without forcing the consumer to wire everything manually.
  • Subtree-scoped configuration — Preferences that apply to a section of the app, not the entire thing. For example, layout configuration for a dashboard section.

A Practical Example: Tabs Component

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.

When NOT to Use Provide/Inject

  • Global state — Provide/inject is scoped to a component subtree. If you 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.
  • Cross-tree communication — If two sibling subtrees need to share data and neither is an ancestor of the other, provide/inject can't help. You need a store.
  • Highly dynamic or complex state — Provide/inject doesn't give you getters, actions, plugins, or devtools. If the state has logic around it, it's outgrowing provide/inject.

Type-Safe Provide/Inject

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 for Global State Management

What It Is

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.

When to Use Pinia

  • Global application state — Auth, user preferences, shopping cart, notification queue, feature flags. Anything that multiple unrelated parts of the app read or write.
  • State that needs devtools — Pinia integrates with Vue DevTools for state inspection, time-travel debugging, and action tracking.
  • SSR-safe shared state — Unlike module-level refs, Pinia creates per-request state in SSR environments (like Nuxt), preventing state bleed between users.
  • Complex state logic — When state has computed derivations, async operations, and multiple consumers, Pinia gives you an organized place for all of it.

When NOT to Use Pinia

  • Local component state — A toggle, a form input, a loading flag that only one component cares about. Use ref() directly.
  • Reusable logic that isn't shared state — Debounce, mouse tracking, timers. These are composables.
  • Tightly coupled component communication — Parent-child or ancestor-descendant data passing. Use props, emits, or provide/inject.

Comparing All Three

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

Decision Flowchart

When you're staring at a piece of state and wondering where it should live, ask these questions in order:

Decision Flowchart for Vue State Management

  1. Does only one component use this state?
    → Yes: Use a local ref or reactive. No abstraction needed.

  2. Am I extracting reusable logic (not shared state)?
    → Yes: Use a composable. Each consumer gets its own instance.

  3. Do I need to pass data down a component subtree without prop drilling?
    → Yes: Use provide/inject. Especially for tightly coupled component groups.

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

They're Not Mutually Exclusive

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.

Common Mistakes

Putting Everything in Pinia

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.

Using Provide/Inject as a Global Store

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.

Shared State Composables for Complex State

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.

Forgetting SSR Implications

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.

Wrapping Up

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.

Start learning Vue.js for free

Daniel Kelly
Daniel Kelly
Daniel is the lead instructor at Vue School and enjoys helping other developers reach their full potential. He has 10+ years of developer experience using technologies including Vue.js, Nuxt.js, and Laravel.

Comments

Latest Vue School Articles

Using Pretext in Vue to Build Variable-Height UI Without Layout Thrash

Using Pretext in Vue to Build Variable-Height UI Without Layout Thrash

Learn how to use Pretext in Vue to measure multiline text without hidden DOM probes, forced reflow, or brittle getBoundingClientRect loops.
Daniel Kelly
Daniel Kelly
Generating Random IDs in Vue.js

Generating Random IDs in Vue.js

How Vue 3.5’s useId() composable gives you stable, unique DOM IDs for forms and accessibility—without manual counters or hydration bugs.
Daniel Kelly
Daniel Kelly
VueSchool logo

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!

Follow us on Social

© All rights reserved. Made with ❤️ by BitterBrains, Inc.