Telerik blogs

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.

Understanding the Concept of State Management in Vue

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:

  • Local state: Exists inside a single component (e.g., a count variable in a counter component)
  • Global state: Shared across multiple components or sections of your app (e.g., user authentication status)

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.

Local State with ref and reactive

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.

Using ref

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.

Using reactive

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.

Sharing State Between Components

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 and Emits

Props let a parent pass state down, while emits let children send events up.

Let’s take a look at this simple demo below:

Parent Component

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>

Child Component

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.

Provide/inject: Avoiding Prop Drilling

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:

Parent Component

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

Child Component

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

State at Scale with Pinia

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:

  • Hot Module Replacement
  • Stronger conventions for team collaboration
  • Integrating with the Vue DevTools, including timeline, in-component inspection and time-travel debugging
  • Server-side rendering support

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:

  • State: This is your actual reactive data. Think of it as the source of truth for your app. For example, a user’s profile details, the item in a cart or whether a modal is open.
  • Store: The centralized container that holds your state. Instead of scattering data across multiple components, the store centralizes it, so any components can access and update it without messy prop drilling.
  • Getters: These act like computed properties for your store. They let you derive or transform state values (for example, calculating the cost of items in a cart) without duplication logic in multiple places.
  • Actions: Functions that update your state. They’re like methods in a Vue component, and they’re where you keep the logic for making changes, whether it’s incrementing a counter, adding an item to a list or fetching data from an API.

Think of it this way:

  • State is what you have
  • Getters are how you view it
  • Actions define how it changes
  • The store is where it all lives

So let’s demonstrate how to integrate Pinia with our counter demo app.

Setting Up Pinia

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.

Creating a Store

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:

State

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.

Getters

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.

Actions

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.

Wrapping Up

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-sq
About the Author

David Adeneye Abiodun

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.

Related Posts

Comments

Comments are disabled in preview mode.