State Management and Data Persistence
The previous chapter completed componentization. We now face the final practical pain point of our project: data loss upon page refresh.
To solve this issue, we will introduce Pinia, the official recommended state management library for Vue, combined with the styling capabilities of Tailwind v4, to implement persistent data storage.
Pinia State Management and Data Persistence
Pinia is a lightweight state management library for Vue.js that allows you to share and manage state between components. You can think of Pinia as a global data warehouse where all components can fetch or update data.
For more information on Pinia, refer to: Pinia Getting Started Tutorial.
Why Use Pinia?
In earlier code, data was all stored within App.vue. As projects grow larger (such as adding statistics pages or user centers), passing data between components becomes a nested nightmare (Props Hell).
- Pinia provides a global data warehouse.
- Any component can directly access data from the warehouse or trigger its methods.
Installation and Initialization:
npm install pinia
npm install @vue/devtools-kit -D
Register in src/main.js:
Example
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import './style.css'
const app = createApp(App)
app.use(createPinia()) // Plugin registration
app.mount('#app')
Create Task Store (Warehouse)
Create a stores directory under src, then create a new file taskStore.js inside src/stores.

We will move all logic originally located in App.vue here.
Example
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
export const useTaskStore = defineStore('task-store', () => {
// --- 1. State ---
// Attempt to read initial values from LocalStorage
const savedTasks = localStorage.getItem('my-tasks')
const tasks = ref(savedTasks ? JSON.parse(savedTasks) : [])
const filter = ref('all')
// --- 2. Getters ---
const filteredTasks = computed(() => {
if (filter.value === 'active') return tasks.value.filter(t => !t.isCompleted)
if (filter.value === 'completed') return tasks.value.filter(t => t.isCompleted)
return tasks.value
})
// --- 3. Actions ---
const addTask = (title) => {
tasks.value.unshift({ id: crypto.randomUUID(), title, isCompleted: false })
}
const removeTask = (id) => {
tasks.value = tasks.value.filter(t => t.id !== id)
}
const toggleTask = (id) => {
const task = tasks.value.find(t => t.id === id)
if (task) task.isCompleted = !task.isCompleted
}
// --- 4. Persistence ---
// Watch changes in tasks and write to LocalStorage when changed
watch(
tasks,
(newVal) => {
localStorage.setItem('my-tasks', JSON.stringify(newVal))
},
{ deep: true }
) // Deeply watch internal object changes in array
return {
tasks,
filter,
filteredTasks,
addTask,
removeTask,
toggleTask
}
})
Refactor App.vue (Connect to Store)
Now App.vue no longer needs to manage data itselfβit just calls the Store.
Example
<script setup>
import { storeToRefs } from 'pinia'
import { useTaskStore } from '@/stores/taskStore'
import TaskHeader from './components/TaskHeader.vue'
import TaskInput from './components/TaskInput.vue'
import TaskFilter from './components/TaskFilter.vue'
import TaskItem from './components/TaskItem.vue'
// Initialize store
const taskStore = useTaskStore()
// Use storeToRefs to maintain reactivity during destructuring
// This allows direct usage of filter and filteredTasks in templates
const { filter, filteredTasks } = storeToRefs(taskStore)
const { addTask, removeTask, toggleTask } = taskStore
</script>
<template>
<div class="min-h-screen py-12 px-4">
<div class="max-w-md mx-auto bg-white rounded-3xl shadow-xl border border-slate-100 overflow-hidden">
<TaskHeader />
<main class="p-6">
<TaskInput @add-task="addTask" />
<TaskFilter v-model="filter" />
<ul class="space-y-3">
<TransitionGroup name="list">
<TaskItem
v-for="task in filteredTasks"
:key="task.id"
:task="task"
@toggle="toggleTask"
@remove="removeTask"
/>
</TransitionGroup>
</ul>
</main>
</div>
</div>
</template>
Detailed Explanation of Concepts
1. What is storeToRefs?
If you write it like this: const { filter } = taskStore, you'll lose reactivity. When modifying filter, the page won't refresh.
- Reason: The store is an object wrapped by
reactive; direct destructuring breaks references. - Solution: Use
storeToRefs. It converts states in the store intorefs, ensuring full reactivity after destructuring.
2. Deep Watching (deep: true) with watch
In persistence logic, we are watching the tasks array.
- Without
{ deep: true }, saving would only be triggered when the entire array is replaced (e.g.,tasks.value = []). - With it enabled, modifications to properties inside objects within the array (like changing a task from "pending" to "completed") will also be captured and saved to disk.
3. Persistence Strategy
We adopted a closed-loop approach: βRead at initialization + Write on changeβ:
- Reading: On store creation, retrieve data from
localStorageso the application has memory. - Writing: Utilize
watchto achieve fully automatic synchronizationβdevelopers no longer need to manually callsetItem.
Final Polishing with Tailwind v4
With the power of the Store, we can enhance the application with some global feedback styles. For example, display task progress in TaskHeader.vue:
Example
<script setup>
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useTaskStore } from '@/stores/taskStore'; // Ensure correct path
// 1. Initialize store
const taskStore = useTaskStore();
// 2. Destructure tasks using storeToRefs to ensure reactivity
// If written as const { tasks } = taskStore;, the progress bar won't update dynamically
const { tasks } = storeToRefs(taskStore);
// 3. Implement progress logic
const progress = computed(() => {
const total = tasks.value.length;
if (total === 0) return 0; // Prevent division by zero
const completedCount = tasks.value.filter(t => t.isCompleted).length;
// Calculate percentage and round
return Math.round((completedCount / total) * 100);
});
</script>
<template>
<header class="bg-linear-to-br from-blue-600 to-indigo-700 p-8 text-white">
<h1 class="text-3xl font-black tracking-tight">TaskHub</h1>
<div class="mt-6">
<div class="flex justify-between items-end mb-2">
<p class="text-blue-100/80 text-xs font-bold uppercase tracking-wider">Completion Progress</p>
<span class="text-2xl font-mono font-black">{{ progress }}%</span>
</div>
<div class="h-2 w-full bg-white/20 rounded-full overflow-hidden backdrop-blur-sm">
<div
class="h-full bg-white shadow-[0_0_15px_rgba(255,255,255,0.5)] transition-all duration-500 ease-out"
:style="{ width: `${progress}%` }"
></div>
</div>
</div>
</header>
</template>
After modification, the interface gains a functional progress bar:

YouTip