Learn how to scale Vue state management with ref/reactive, props/emits, provide/inject and Pinia as your app grows.
State is one of the first core concepts you’ll encounter when building anything interactive with Vue. Whether it’s the text in a form, the items in a cart or the logged-in user’s profile, managing that state properly is essential for keeping your app stable, reactive and easy to scale.
Vue 3 introduced the Composition API along with a revamped reactivity system, giving developers more powerful and flexible ways to manage state than ever before. In this guide, we’ll explore how Vue 3 handles state—starting from local state with ref
and reactive
, to sharing data with props
and provide/inject
, and finally leveling up to Pinia, Vue’s official state management library. By the end, you’ll have a clear roadmap for choosing the right tool for your app’s needs.
State is at the heart of every interactive Vue app. It’s what makes your UI dynamic—update the state, and Vue automatically reflects those changes in the DOM.
Broadly, there are two types of state:
count
variable in a counter component)By default, every Vue component maintains its own reactive state, which we often refer to as local state. Let’s start here before exploring how to share state across components and eventually manage it globally with Pinia.
To manage local reactive state, the Vue 3 Composition API offers two main tools: ref
and reactive
. If you’re new to Vue, deciding which to use can be confusing—so let’s break it down, building on our understanding of local state.
Use ref
when working with primitive values (numbers, strings, booleans).
<script setup>
import { ref } from 'vue'
//state
const count = ref(0)
//actions
function increment() {
count.value++
}
</script>
<!-- view -->
<template>
<button @click="increment">Count is: {{ count }}</button>
</template>
Here,count
is wrapped in a ref
, and its value is accessed via .value
.
Use reactive
when working with objects or arrays.
<script setup>
import { reactive } from 'vue'
const user = reactive({
name: ' ',
age: 25
})
</script>
<template>
<input v-model="user.name" placeholder="Enter your name" />
<p>{{ user.name }} is {{ user.age }} years old.</p>
</template>
Behind the scenes, Vue wraps the object in a Proxy, so it can automatically track changes and update the DOM.
Local state is the simplest example of Vue’s one-way data flow: the state drives the UI. But once multiple components need to share the same piece of state, managing it locally becomes difficult, and that’s when we move beyond local state.
Local state works fine for a single component, but what if multiple components need the same data? Vue gives us several ways to share state: props/emits and provide/inject.
Props let a parent pass state down, while emits let children send events up.
Let’s take a look at this simple demo below:
You can pass data from parent to child via props.
<!-- Parent.vue -->
<template>
<Child :count="count" @increment="count++" />
</template>
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
const count = ref(0)
</script>
When the child needs to update the parent’s state, it can emit an event.
<!-- Child.vue -->
<template>
<button @click="$emit('increment')">Clicked</button>
</template>
<script setup>
defineProps(['count'])
defineEmits(['increment'])
</script>
This approach works well for small apps, but if you have deeply nested components, passing props and emits around quickly becomes messy, leading to another problem known as prop drilling. To avoid this, Vue provides an alternative option: provide/inject.
The provide/inject
API in Vue makes it easy for a parent component to share data with its children, no matter how deeply nested they are, without having to pass props down through every intermediate layer.
Let’s take a look at this simple code demo below:
<!-- ParentComponent.vue -->
<script setup>
import { ref, provide } from 'vue'
import ChildComponent from './ChildComponent.vue'
const count = ref(0)
provide('count', count)
const increment = () => {
count.value++
}
</script>
<template>
<div>
<h2>Parent Count: {{ count }}</h2>
<button @click="increment">Increment</button>
<!-- Pass down without props -->
<ChildComponent />
</div>
</template>
The provide()
function is used in a parent component to make data available to its descendants. It takes two arguments: an injection key and a value. The key can be either a string or a symbol, and descendant components will use that key to access the corresponding value through inject()
. A single component isn’t limited to one call; you can call provide()
multiple times with different keys to share different values.
The second argument of provide()
is the data you want to share, which can be of any type—primitives, objects, functions or even reactive state like ref
or reactive
. When you provide a reactive value, Vue doesn’t pass a copy; it establishes a live connection, allowing descendant components that inject()
it to automatically stay in sync with the provider.
<!-- ChildComponent.vue -->
<script setup>
import GrandChildComponent from './GrandChildComponent.vue'
</script>
<template>
<div>
<h3>I am the Child</h3>
<!-- Notice: no prop passing -->
<GrandChildComponent />
</div>
</template>
To inject data provided by an ancestor component, make use of the inject()
function in the GrandChildComponent.vue
:
<!-- GrandChildComponent.vue -->
<script setup>
import { inject } from 'vue'
const count = inject('count')
</script>
<template>
<div>
<p>Grandchild sees count: {{ count }}</p>
</div>
</template>
In the code demo above, the ParentComponent provides the reactive count
, so the ChildComponent doesn’t need to do anything—it just passes the slot/children down. Then the GrandChildComponent can directly inject count
and stay reactive to parent updates
This is a basic demo of how to use the provide/inject
pattern; however, if you want to learn more, this post covers it in great detail: Vue Basics: Exploring Vue’s Provide/Inject Pattern.
The provide/inject
pattern is perfect for avoiding prop drilling in medium-sized apps. However, as your app grows, managing dependencies this way can get complicated. For larger and complex apps, we need a dedicated state management solution that is more structured and scalable, which is where libraries like Pinia come in.
As your app grows, manually managing state across multiple components quickly turns into a headache. The patterns we covered earlier work fine for smaller projects, but once you’re building a large-scale production application, there are many things to consider:
You need a central store, a single source of truth that multiple components can read and write to, and that is where Pinia comes in.
Pinia is the official state management library for Vue 3 and the successor to Vuex. It’s designed to handle all the scenarios we listed above. It’s much simpler, more intuitive and built to work seamlessly with the Composition API.
Before we dive into the code demo, let’s make sure we’re clear on the fundamentals. At its core, state management is about where your data lives, how you read it and how to update it. Pinia formalizes this with four concepts:
Think of it this way:
So let’s demonstrate how to integrate Pinia with our counter demo app.
Setting up Pinia for a Vue 3 single-page application is straightforward. If you’re creating a new project from scratch using the Vue CLI or create-vue, the setup wizard will even ask if you want to use Pinia as your state management of choice.
To set up Pinia manually in a new project—or add it to an existing app—follow these steps:
First, install the Pinia package using either npm
or yarn
:
npm install pinia
# or
yarn install pinia
To register Pinia in your app, open your entry file (usually main.js
or main.ts
) where the app is mounted, and call app.use(pinia)
on your Vue application instance:
main.js
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
app.mount('#app')
Now we can move to creating the store for the counter demo app.
Here’s a simple counter store that demonstrates all four concepts. To create a store, start by making a new file that holds its code. A good practice is to place these files inside a dedicated stores
folder to keep your project organized.
CounterStore.js
export const useCounterStore = defineStore('counter', {
----
})
To create a store, we use Pinia’s defineStore
method. It takes two main arguments: the first is the store’s id
, which must be unique across your application. You can name it anything you like, but for this example, counter
is the most fitting since that’s exactly what our store manages.
The second argument is an object that defines the store’s options. Let’s break down what we can include inside it:
The first option to define in a store is state
. If you’ve worked with Vue’s Options API, this will feel familiar. It’s simply a function that returns an object holding all the reactive data your store should manage. For our counter app demo, we’ll add count
property to the state:
export const useCounterStore = defineStore('counter', {
// State — the reactive shared data
state: () => ({
count: 0,
})
Then we can easily import this store in our CounterButton.vue
component and make use of the count
states.
CounterButton.vue
<script setup>
import { useCounterStore } from '../stores/CounterStore.js’
const counter = useCounterStore()
</script>
<template>
<div>
<button >
Clicked {{ counter.count }} times
</button>
</div>
</template>
In the above code example, we imported the useCounterStore
and then called the method. This will return a copy of the counter store that we created earlier. The state in this store is global, meaning any updates made to it will automatically be reflected across all components that use the store.
Just like Vue’s computed properties, Pinia stores allow us to define getters
. A getter is essentially a computed value that’s derived from the store’s state. They’re useful when you want to transform, filter or calculate something based on the existing state without duplicating logic across components. For example, we can calculate the multiplication of the current state using the getter method:
Update your CounterStore.js
with the following code:
export const useCounterStore = defineStore('counter', {
// State — the reactive shared data
state: () => ({
count: 0,
})
// Getters — derived state
getters: {
doubleCount: (state) => state.count * 2
},
})
That is it. Now we now have a doubleCount
property that we can use across any component.
Create a CounterDispay.vue
component to use the doubleCount
property to display message to the user.
<script setup>
import { useCounterStore } from '../stores/CounterStore.js'
const counter = useCounterStore()
</script>
<template>
<div>
<p>Current Count: {{ counter.count }}</p>
<p>Double Count: {{ counter.doubleCount }}</p>
</div>
</template>
Getters are synchronous by design. If you need to perform asynchronous work (like fetching data), use an action instead.
The last option we can define in our store is actions. Think of actions as the store’s version of component methods—they encapsulate the logic for changing state or performing tasks. Unlike getters, which are only for deriving and returning data, actions are designed to update state and handle side effects.
One major advantage of actions is that they can be asynchronous, unlike getters. This makes them ideal for tasks such as fetching data from an API, handling form submissions or performing any operation that takes time before committing the result back to the store. For example, this is a good location to create a logic to increment the state count
or fetch the initial count
data from your API.
Open our CounterStore.js
and update with the following code:
export const useCounterStore = defineStore('counter', {
// State — the reactive shared data
state: () => ({
count: 0,
})
// Getters — derived state
getters: {
doubleCount: (state) => state.count * 2
},
// Actions — logic to update state
actions: {
increment() {
this.count++
},
async fetchInitialCount() {
const res = await fetch('/api/count')
const data = await res.json()
this.count = data.value
}
}
}
})
Now inside a CounterButton.vue
component, you can call the action instead of directly mutating the state:
<script setup>
import { useCounterStore } from '../stores/CounterStore.js’
const counter = useCounterStore()
</script>
<template>
<button @click="counter.increment">Increment</button>
<button @click="counter.fetchInitialCount">Load Initial Count</button>
<p>Count is: {{ counter.count }}</p>
</template>
From the code modification above, the increment()
is a simple action that directly modifies the store’s state, while the fetchInitialCount()
demonstrates that action also handles asynchronous tasks, like timers or APIs. And since Pinia stores are reactive, once an action updates the state, all components using that store will instantly reflect the new value.
State management in Vue doesn’t have to be overwhelming. Start small with ref
and reactive
for local state. When components need to communicate, props
and emits
are a natural fit. As your app grows, provide/inject
helps reduce prop drilling and keeps things organized.
But when your application requires a state that’s shared and consistent across many components, Pinia stands out. It provides a centralized, scalable store that serves as the single source of truth for your app.
Knowing when to use each approach is the real key. You don’t need Pinia from day one, but as your project becomes more complex, you’ll appreciate its structure and reliability. With these options in hand, you can manage state confidently—whether you’re building a small widget or a full-scale production app.
If you want to go beyond the basics, the official Pinia documentation is the best next step. It dives into plugins, advanced patterns, devtools integration and more—all explained in a clear and practical way.
David Adeneye is a software developer and a technical writer passionate about making the web accessible for everyone. When he is not writing code or creating technical content, he spends time researching about how to design and develop good software products.