Vue.js 3 · Phần 8 — Pinia: Quản lý State
State dùng chung toàn app với Pinia: setup store theo Composition API, state/getters/actions, dùng store trong component và router, giữ reactivity khi destructure với storeToRefs, và vì sao Pinia thay thế Vuex.
provide/inject (Phần 4) hợp cho ngữ cảnh cục bộ; nhưng state dùng khắp app — người dùng đăng nhập, giỏ hàng, cấu hình — cần một nơi tập trung có devtools, test được và SSR-safe. Đó là Pinia, thư viện state management chính thức của Vue, thay thế Vuex.
1. Cài đặt
npm install pinia
// main.ts
import { createPinia } from 'pinia';
createApp(App).use(createPinia()).mount('#app');
2. Định nghĩa store (setup style)
Pinia có hai cú pháp; ta dùng setup store vì nó giống hệt <script setup> bạn đã quen: ref là state, computed là getter, hàm là action.
// stores/counter.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useCounterStore = defineStore('counter', () => {
// state
const count = ref(0);
// getters (giá trị dẫn xuất, được cache)
const double = computed(() => count.value * 2);
// actions (đồng bộ hoặc async)
function increment() {
count.value++;
}
return { count, double, increment };
});
Tham số đầu 'counter' là id duy nhất (dùng cho devtools và SSR). Mọi thứ return ra đều dùng được từ component.
3. Dùng store trong component
<script setup lang="ts">
import { useCounterStore } from '@/stores/counter';
import { storeToRefs } from 'pinia';
const counter = useCounterStore();
// ✗ destructure trực tiếp làm MẤT reactivity
// const { count, double } = counter;
// ✓ storeToRefs giữ reactivity cho state & getters
const { count, double } = storeToRefs(counter);
// action thì destructure thẳng được (không cần reactive)
const { increment } = counter;
</script>
<template>
<button @click="increment">{{ count }} → {{ double }}</button>
</template>
Đây là điểm vấp số một với Pinia: storeToRefs cho state/getters, lấy action trực tiếp. Lý do giống bẫy destructure ở Phần 3 — tách property khỏi reactive object làm đứt liên kết.
4. Store thực tế — auth
// stores/auth.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { User } from '@/types';
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null);
const token = ref<string | null>(localStorage.getItem('token'));
const isLoggedIn = computed(() => !!token.value);
const isAdmin = computed(() => user.value?.role === 'admin');
async function login(email: string, password: string) {
const res = await api.post('/login', { email, password });
token.value = res.token;
user.value = res.user;
localStorage.setItem('token', res.token);
}
function logout() {
user.value = null;
token.value = null;
localStorage.removeItem('token');
}
return { user, token, isLoggedIn, isAdmin, login, logout };
});
Dùng được cả ngoài component — ví dụ trong navigation guard (Phần 7):
router.beforeEach((to) => {
const auth = useAuthStore(); // phải gọi BÊN TRONG guard, không ở top-level module
if (to.meta.requiresAuth && !auth.isLoggedIn) return { name: 'login' };
});
Gọi
useStore()bên trong hàm/setup, không ở top-level của file module — vì Pinia phải được cài (app.use(createPinia())) trước khi store khả dụng.
5. $patch, $reset & cập nhật nhiều field
const store = useCartStore();
// cập nhật nhiều state trong một lần (gộp một lần trigger)
store.$patch({ items: [], total: 0 });
store.$patch((state) => {
state.items.push(newItem);
state.total += newItem.price;
});
store.$reset(); // chỉ với option store; setup store tự viết action reset
6. Pinia vs Vuex vs composable toàn cục
| Pinia | Vuex 4 | composable + module ref | |
|---|---|---|---|
| Boilerplate | ít | nhiều (mutations…) | ít nhất |
| TypeScript | xuất sắc | kém | tốt |
| Devtools | có (time-travel) | có | không |
| SSR | hỗ trợ sẵn | thủ công | dễ rò state giữa request |
Pinia thắng vì gỡ bỏ khái niệm mutations thừa của Vuex, type suy ra tự động, và an toàn SSR. Vuex coi như di sản. Composable với ref ở module scope có thể làm store đơn giản, nhưng thiếu devtools và rủi ro chia sẻ state giữa các request khi SSR — Pinia lo những việc đó.
7. Bài tập
1. Vì sao const { count } = useCounterStore() mất reactivity còn const { increment } = useCounterStore() thì không sao?
Lời giải
count là state reactive; destructure tách nó khỏi store proxy → giá trị tĩnh. Dùng storeToRefs. increment là hàm, không phải state reactive, nên lấy trực tiếp vẫn chạy đúng.
2. Vì sao phải gọi useAuthStore() bên trong guard chứ không ở top-level file router?
Lời giải
Store chỉ khả dụng sau khi app.use(createPinia()). Gọi ở top-level module router (chạy lúc import) có thể trước khi Pinia được cài → lỗi.
3. Khi nào dùng $patch thay vì gán từng field?
Lời giải
Khi đổi nhiều field cùng lúc — $patch gộp thành một lần cập nhật/trigger và hiện rõ trong devtools như một thao tác.
Điểm chính
- Pinia là store toàn app: setup store =
ref(state) +computed(getter) + hàm (action). - Dùng
storeToRefscho state/getters; lấy action trực tiếp. - Store dùng được ngoài component (router guard) — nhưng gọi
useStore()bên trong hàm. $patchgộp nhiều cập nhật; thiết kế action async cho gọi API.- Pinia thay thế Vuex: ít boilerplate, TS tốt, devtools, SSR-safe.
Phần tiếp theo
State app thường đến từ server. Phần 9 — Async, Suspense & Data Fetching đi sâu xử lý bất đồng bộ: pattern fetch có loading/error, component bất đồng bộ với <Suspense>, error boundary với onErrorCaptured, và vì sao một thư viện như TanStack Query Vue đáng cân nhắc cho cache server state.