Blog
·
6 min read

Navigating State Management in Vue: Composables, Provide/Inject, and Pinia

Michael Hoffmann

Michael Hoffmann

@mokkapps

Navigating State Management in Vue: Composables, Provide/Inject, and Pinia Image

State management in Vue is one of those topics that seems simple until your app grows a little - and then you’re suddenly juggling prop drilling, duplicated logic, and confusing reactivity bugs. Over the years I’ve tried different approaches and learned that the right answer usually isn’t "one tool to rule them all" but choosing the right approach for the problem at hand. In this article I’ll walk you through composables, provide/inject, and Pinia — when to use each, how to use them well, and practical examples you can copy into your projects.

Introduction to State Management in Vue

When I start a new Vue project I always ask three questions before picking a state approach:

  • How widely does this state need to be shared? (single component, subtree, global)
  • Is the logic reusable across components or tied to a specific component?
  • Do I need advanced features like SSR, persistence, or strong typing?

Vue gives us multiple, complementary tools to solve these needs: Composables (Composition API logic encapsulation), Provide/Inject (scoped sharing in a subtree), and Pinia (centralized/global stores). Each has strengths and trade-offs. Below I’ll dig into each with patterns, code, and rules of thumb I actually use day-to-day.

Understanding Composables: When and How to Use Them

Composables are reusable functions that encapsulate state and logic using the Composition API. Think of them as the “utility modules” for stateful behavior.

Why I reach for a composable:

  • When logic is reusable across unrelated components (e.g., fetch logic, form handling, timers).
  • For small pieces of local state that aren’t global - counters, visibility toggles, input validation, etc.
  • When performance matters (they’re lightweight and don't force global reactivity).

A minimal counter composable example:

useCounter.ts
import { ref } from 'vue'

export function useCounter(initial = 0) {
  const count = ref(initial)
  const increment = () => ++count.value
  const decrement = () => --count.value
  const reset = () => {
      count.value = initial
  }

  return { count, increment, decrement, reset }
}

Best practices I follow:

  • Name composables with a use prefix: useAuth, useFetch, useCounter. This makes intent clear.
  • Group composables by feature or domain (e.g., /composables/auth, /composables/ui).
  • Keep state encapsulated; expose only what callers need (avoid leaking internal refs unnecessarily).
  • If many components need the same state instance rather than independent instances, consider switching to Provide/Inject or Pinia rather than making a composable that returns a shared object — otherwise you get implicit singletons that are harder to reason about.

When not to use a composable:

  • When the state must be truly global and monolithic (Pinia is better).
  • When you must share state only within a subtree but not across the whole app (use Provide/Inject instead).

Leveraging Provide/Inject for Local State Sharing

Provide/Inject lets a parent component provide values (reactive data, functions) and descendant components inject them without prop drilling. I use this pattern when state belongs to a component subtree - e.g., a theming context, a form with nested children, or a modal manager.

Example: simple theming using Provide/Inject

Parent.vue
<script setup lang="ts">
import { provide, reactive } from 'vue'

const theme = reactive({ color: 'light', accent: '#009688' })
const toggle = () => theme.color = theme.color === 'light' ? 'dark' : 'light'

provide('theme', theme)
provide('toggleTheme', toggle)
</script>
Child.vue
<script setup lang="ts">
import { inject } from 'vue'

const theme = inject('theme')
const toggleTheme = inject('toggleTheme')
</script>

When to use Provide/Inject:

  • The state is scoped to a subtree and not needed globally.
  • You want to avoid prop drilling for deeply nested components.
  • You’re implementing context-like things: theme, localization, per-widget configuration, or a modal stack.

Caveats and best practices:

  • Provide/Inject bypasses the component interface, so document the provided keys carefully and prefer symbol keys to avoid collisions.
  • Avoid overusing it; it can make component relationships implicit and harder to trace compared to props.
  • Combine with composables: provide a single composable instance (e.g., const modal = useModal(); provide('modal', modal)) so you get the composable API and scoped sharing together.

Harnessing the Power of Pinia for Centralized State Management

Pinia is the officially recommended state library for Vue 3. I use Pinia for global state that multiple, unrelated components or pages need access to - authentication, user preferences, shopping cart, complex domain models.

Key reasons to choose Pinia:

  • Intuitive, modular API and great TypeScript support.
  • Supports SSR hydration and plugin ecosystem (persistence, logger, etc.).
  • Encourages splitting concerns into smaller stores rather than a single monolithic store.

Example: simple auth store with Pinia

useAuthStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useAuthStore = defineStore('auth', () => {
  const user = ref(null)
  const token = ref(null)

  const isLoggedIn = computed(() => !!user.value)

  function setUser(payload) {
    user.value = payload.user
    token.value = payload.token
  }

  function logout() {
    user.value = null
    token.value = null
  }

  return { user, token, isLoggedIn, setUser, logout }
})

Usage in a component:

Navbar.vue
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
import { storeToRefs } from 'pinia'

const auth = useAuthStore()
const { isLoggedIn } = storeToRefs(auth)
</script>

Pinia best practices I follow:

  • Create small, focused stores (authStore, cartStore, uiStore) instead of one giant store.
  • Use plugins for cross-cutting concerns: persistence plugin for localStorage, logger for dev debugging.
  • Type your stores when using TypeScript for safer refactoring and autocompletion.
  • Prefer actions for async logic and mutations inside actions - keep the state mutations explicit.
  • For SSR, make sure to return fresh stores per request (Pinia supports SSR patterns).

Comparative Analysis: Choosing the Right Tool for the Job

I like to compare the three on the dimensions that matter most in real projects:

  • Scope
    • Composables: local to component or independent instances per consumer (or implicitly shared if you intentionally export a single instance).
    • Provide/Inject: subtree-scoped.
    • Pinia: global/app-wide.
  • Use case
    • Composables: reusable logic (fetch, form handling, timers).
    • Provide/Inject: contextual settings for a component subtree (theme, nested form context).
    • Pinia: global app state, multi-page shared state, complex interdependent state.
  • Complexity & tooling
    • Composables: low overhead, great for simple logic.
    • Provide/Inject: lightweight, but can make dependency relationships implicit.
    • Pinia: more structure, supports plugins, SSR, type-safety; better for larger apps.
  • Reactivity sharing
    • Composables: returns isolated reactive state unless intentionally shared.
    • Provide/Inject: can provide reactive objects to descendants.
    • Pinia: stores are reactive by design and accessible anywhere.

Rules of thumb I use:

  • Start with composables for small apps and features. If you find multiple components need the same instance of state, either lift it up (parent-provide) or move it to Pinia.
  • Use Provide/Inject when a feature must be scoped to a subtree and you want to avoid prop drilling — e.g., a multi-field form with many nested inputs.
  • Use Pinia when state must be accessible across the app, persisted, or when you want the developer ergonomics and plugin support Pinia provides.

Conclusion and Best Practices

State management in Vue doesn’t have to be either/or. Composables, Provide/Inject, and Pinia are complementary tools - choose based on scope, reusability, and complexity.

My checklist before implementing state:

  • Is the state subtree-scoped? Use Provide/Inject.
  • Is it local/reusable logic but independent per consumer? Use a composable.
  • Is it global or shared across pages or unrelated components? Use Pinia.
  • Do I need SSR, persistence, or a plugin ecosystem? Pinia is the best fit.
  • Keep interfaces explicit: name keys, use symbols for Provide/Inject, prefix composables with use, and keep stores modular.
I will never share any of your personal data. You can unsubscribe at any time.
If you found this article helpful.You will love these ones as well.
A Comprehensive Guide to Data Fetching in Nuxt 3 Image

A Comprehensive Guide to Data Fetching in Nuxt 3

Analyze Memory Leaks in Your Nuxt App Image

Analyze Memory Leaks in Your Nuxt App

Building a Polite Newsletter Popup With Nuxt 3 Image

Building a Polite Newsletter Popup With Nuxt 3

Building a Vue 3 Desktop App With Pinia, Electron and Quasar Image

Building a Vue 3 Desktop App With Pinia, Electron and Quasar