jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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'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

PiniaVuex 4composable + module ref
Boilerplateítnhiều (mutations…)ít nhất
TypeScriptxuất sắckémtốt
Devtoolscó (time-travel)không
SSRhỗ trợ sẵnthủ côngdễ 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 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 storeToRefs cho 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.
  • $patch gộ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.