Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 61

Vuex State Management in Components

Recommended resources

Hello! Welcome back to our journey with Pinia.

Introduction

In our last session, we successfully centralized our to-do list's logic by building a complete Pinia store. We defined the state (the tasks array), getters (like uncompletedTasksCount), and actions (like addTask and deleteTask). Our taskStore is now the single source of truth for all task-related data and logic.

However, if you look at App.vue, it's still acting as a middleman. It receives data from the store only to pass it down as props to child components, and it listens for events from children only to call the store's actions. This pattern is known as prop drilling and event bubbling.

Today's lesson is about eliminating that middleman. You will learn how to access and manipulate the store's state directly from any component. This is the primary reason for using a state management library like Pinia. By the end of this lesson, you'll have refactored the to-do app to be much cleaner and more efficient, truly unlocking the power of a centralized store.



Part 1: The Problem with Prop Drilling

Before we fix the problem, let's clearly understand it. As an application grows, you might have components nested several layers deep. Passing data from a top-level component down to a deeply nested child through all the intermediate components is called "prop drilling." It makes your components tightly coupled and hard to refactor.

The video below gives an excellent visual explanation of this issue and why a centralized store is the solution.

Vue JS with Composition API & Pinia Full Tutorial

Watch this segment from 'Vue JS with Composition API & Pinia Full Tutorial' to see a clear illustration of how component communication becomes complex as an app grows, and how a store simplifies it.

Watch from 00:55:12 to 00:58:52. Focus on the diagrams that contrast passing data through components versus having components communicate directly with a central store.

As the video shows, our goal is to move from this complex web of props and events to a cleaner architecture where components interact directly with the store.

Vue.js Weather App Component Diagram with Pinia Integration
This diagram illustrates our target architecture. Notice how components like `WeatherContent` and `CityList` both interact with the central `Pinia Data Store` independently, rather than passing data through each other.


Part 2: Hands-On Refactoring

Now, let's apply this concept. We will refactor our TodoList and TodoForm components to use the taskStore directly, and then we'll clean up App.vue.

Step 1: Refactor TodoList.vue

This component currently receives tasks via props and emits events when a user wants to delete or toggle a task. Let's change it to communicate with the store.

  1. Open src/components/TodoList.vue.
  2. Import useTaskStore.
  3. Remove the defineProps and defineEmits macros.
  4. Instantiate the store.
  5. Update the template to get tasks from the store and call store actions directly.

Your TodoList.vue file should look like this:

<!-- src/components/TodoList.vue -->
<script setup>
import { useTaskStore } from '@/stores/taskStore'

// No more props or emits!

const taskStore = useTaskStore()
</script>

<template>
  <div>
    <!-- Iterate directly over the tasks in the store -->
    <div v-for="task in taskStore.tasks" :key="task.id">
      <span>{{ task.text }}</span>
      <span :class="{ 'completed': task.isComplete }">
        {{ task.isComplete ? ' (Completed)' : '' }}
      </span>
      <!-- Call store actions directly from the click handlers -->
      <button @click="taskStore.toggleComplete(task.id)">Toggle</button>
      <button @click="taskStore.deleteTask(task.id)">Delete</button>
    </div>
  </div>
</template>

<style scoped>
.completed {
  text-decoration: line-through;
  color: #888;
}
/* Add some basic styling for layout */
div > div {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 5px;
}
</style>

We have now made TodoList.vue self-sufficient. It knows how to get the data it needs and how to perform actions on that data.

Step 2: Refactor TodoForm.vue

Next, let's do the same for the form.

  1. Open src/components/TodoForm.vue.
  2. Import useTaskStore and remove defineEmits.
  3. Instantiate the store.
  4. Update the handleSubmit method to call the store's addTask action directly.

Here's the updated code for TodoForm.vue:

<!-- src/components/TodoForm.vue -->
<script setup>
import { ref } from 'vue'
import { useTaskStore } from '@/stores/taskStore'

const newTaskText = ref('')
const taskStore = useTaskStore()

// No more emits!

const handleSubmit = () => {
  // Call the action directly
  taskStore.addTask(newTaskText.value)
  newTaskText.value = ''
}
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <input type="text" v-model="newTaskText" placeholder="Add a new task">
    <button type="submit">Add Task</button>
  </form>
</template>

Step 3: Simplify App.vue

With our child components now connected to the store, App.vue becomes incredibly simple. It's now just a layout component, responsible for placing other components on the page, not for managing logic.

  1. Open src/App.vue.
  2. Remove all the prop bindings and event listeners from <TodoList> and <TodoForm>.
  3. Remove the local handler functions (addTask, deleteTask, toggleComplete) from the <script setup> block.

Your simplified App.vue should look like this:

<!-- src/App.vue -->
<script setup>
import { useTaskStore } from '@/stores/taskStore'
import TodoForm from './components/TodoForm.vue'
import TodoList from './components/TodoList.vue'

const taskStore = useTaskStore() // We still need it for the summary message
</script>

<template>
  <h1>My To-Do List</h1>
  
  <p v-if="taskStore.totalTasks > 0">
    You have {{ taskStore.uncompletedTasksCount }} of {{ taskStore.totalTasks }} tasks remaining.
  </p>
  <p v-else>
    You have no tasks! Add one below.
  </p>
  
  <!-- No more props or events! So much cleaner. -->
  <TodoForm />
  <TodoList />

</template>

Test your application. Everything should still work perfectly, but the underlying code is now much more robust, maintainable, and scalable.



Part 3: A Best Practice: storeToRefs

There's one important detail to cover. In our components, we accessed state and getters like taskStore.tasks. This is perfectly fine. However, sometimes you might want to destructure properties from the store for convenience:

const { tasks, uncompletedTasksCount } = taskStore; // This is WRONG!

Doing this will break reactivity. The destructured variables will be plain values, not reactive refs, so your component won't update when the store changes.

Pinia provides a helper function called storeToRefs to solve this. It creates reactive refs for all state properties and getters in the store.

Let's refactor TodoList.vue to use storeToRefs.

  1. Open src/components/TodoList.vue.
  2. Import storeToRefs from pinia.
  3. Use it to create reactive refs for the tasks.
<!-- src/components/TodoList.vue -->
<script setup>
import { useTaskStore } from '@/stores/taskStore'
import { storeToRefs } from 'pinia' // 1. Import

const taskStore = useTaskStore()

// 2. Use storeToRefs to create reactive refs
// Now `tasks` is a reactive ref, just like if you defined it with ref()
const { tasks } = storeToRefs(taskStore)

// Actions can be destructured directly because they are just functions
const { toggleComplete, deleteTask } = taskStore
</script>

<template>
  <div>
    <!-- No need to use `taskStore.` prefix anymore -->
    <div v-for="task in tasks" :key="task.id">
      <span>{{ task.text }}</span>
      <span :class="{ 'completed': task.isComplete }">
        {{ task.isComplete ? ' (Completed)' : '' }}
      </span>
      <!-- Call the destructured actions -->
      <button @click="toggleComplete(task.id)">Toggle</button>
      <button @click="deleteTask(task.id)">Delete</button>
    </div>
  </div>
</template>

<style scoped>
/* ... styles ... */
</style>

Key rule:

  • Use storeToRefs when destructuring state and getters.
  • You can destructure actions directly.

This article provides another example of this pattern.

Leveraging Pinia to simplify complex Vue state management

The article 'Leveraging Pinia to simplify complex Vue state management' demonstrates how to properly access store data within a component.

Navigate to the section 'Creating the posts view'. Pay close attention to the code block and the explanation immediately following it, which describes why storeToRefs() is used for posts, loading, and error, but not for the fetchPosts action.



Part 4: Proving the State is Shared

The real "aha!" moment comes when you see a second, unrelated component share the same state. Let's prove it works.

We will create a new component, TodoSummary.vue, and move the summary text from App.vue into it.

  1. Create a new file: src/components/TodoSummary.vue.

  2. Add the following code to it. Notice it imports and uses the store completely independently.

    <!-- src/components/TodoSummary.vue -->
    <script setup>
    import { storeToRefs } from 'pinia';
    import { useTaskStore } from '@/stores/taskStore';
    
    const taskStore = useTaskStore();
    // Use storeToRefs to get reactive getters
    const { totalTasks, uncompletedTasksCount } = storeToRefs(taskStore);
    </script>
    
    <template>
        <p v-if="totalTasks > 0">
            You have {{ uncompletedTasksCount }} of {{ totalTasks }} tasks remaining.
        </p>
        <p v-else>
            You have no tasks! Add one below.
        </p>
    </template>
    
  3. Update App.vue to use this new component.

    <!-- src/App.vue -->
    <script setup>
    import TodoForm from './components/TodoForm.vue'
    import TodoList from './components/TodoList.vue'
    import TodoSummary from './components/TodoSummary.vue' // Import the new component
    </script>
    
    <template>
      <h1>My To-Do List</h1>
      
      <TodoSummary /> <!-- Use it here -->
      
      <TodoForm />
      <TodoList />
    </template>
    

Now, TodoList, TodoForm, and TodoSummary are all sibling components. Yet, when you add a task using TodoForm, both TodoList and TodoSummary update instantly. This is the power of a centralized, reactive store.

The following article demonstrates this exact principle by creating two separate views that share state from the same counter store.

Setting up Your First Pinia Store in Vue with TypeScript - dbi services

The article 'Setting up Your First Pinia Store in Vue' clearly shows how state can be shared across multiple components.

Read the section titled 'Sharing the data across multiple component'. It walks through creating a second view that uses the same store, proving that changes in one place are reflected everywhere.



Conclusion

Fantastic work! You've taken a massive step in building scalable Vue applications. By connecting your components directly to the Pinia store, you've decoupled them and made your overall architecture much cleaner.

Key Takeaways:

  • Any component can import and use a Pinia store to access state or call actions.
  • This pattern eliminates the need for "prop drilling" and "event bubbling," simplifying parent components like App.vue.
  • The storeToRefs() utility is essential for destructuring state and getters from a store while maintaining reactivity.
  • Actions can be destructured directly from the store instance.
  • Multiple, unrelated components can react to the same state changes, proving the power of a single source of truth.

In the next lesson, we will formally recap the problem of prop drilling and solidify why Pinia is the ideal solution, which will conclude this module on state management.