Skip to main content

Pinia

Introduction

โดยเริ่มต้น Pinia เป็นการทดลองในการเก็บข้อมูลแบบใหม่ ของ Vue โดยใช้กับ Composition API เมื่อประมาณ พฤศจิกายน 2019, นับแต่นั้นมา, มันก็ยังใช้หลักการเดียวกันต่อมา แต่ปัจจุบัน สามารถใช้งานได้ทั้ง Vue 2 และ Vue 3 และมันไม่จำเป็นต้องใช้ composition API โดยการสอนต่อไปนี้ จะเน้นการใช้ Vue 2 และ Vue 3

Why should I use Pinia?

Pinia เป็น Library สำหรับจัดการ State ใน Vue ซึ่งช่วยให้คุณสามารถแชร์ State ระหว่าง Component ได้ หากคุณคุ้นเคยกับ Composition API คุณอาจคิดว่าคุณสามารถแชร์ State ทั่วทั้ง Application ได้ง่าย ๆ ด้วยการใช้ export const state = reactive({}) ซึ่ง สามารถทำได้ สำหรับ Application แบบหน้าเดียว (Single Page Applications - SPA) แต่สำหรับ Application ที่ใช้การ Render ฝั่ง Server (Server-Side Rendering - SSR) วิธีนี้จะทำให้ Applicaiton ของคุณเสี่ยงต่อช่องโหว่ด้านความปลอดภัย อย่างไรก็ตาม แม้แต่ในแอป SPA ขนาดเล็ก การใช้ Pinia จะให้ประโยชน์มากมาย เช่น:

  • เครื่องมือสำหรับการทดสอบ
  • ปลั๊กอิน: ขยายความสามารถของ Pinia ด้วยปลั๊กอิน
  • รองรับ TypeScript หรือมีการเติมคำอัตโนมัติสำหรับผู้ใช้ JavaScript
  • รองรับการ Render ฝั่ง Server (SSR)
  • รองรับ Devtools
  • ไทม์ไลน์ เพื่อติดตามการกระทำ (actions) และการเปลี่ยนแปลง State (mutations)
  • Store ปรากฏใน Component ที่ใช้งานมัน
  • การ Debug ที่ง่ายขึ้น
  • การแทนที่โมดูลแบบทันที (hot module replacement)
    • แก้ไข Store ของคุณโดยไม่ต้องรีโหลดหน้า
    • เก็บ State เดิมในขณะที่กำลัง Develop

หากคุณยังมีข้อสงสัย ลองดูคอร์ส Mastering Pinia อย่างเป็นทางการ ในช่วงเริ่มต้นคอร์ส เราจะครอบคลุมวิธีสร้างฟังก์ชัน defineStore() ด้วยตัวเอง จากนั้นจึงย้ายไปใช้ API อย่างเป็นทางการของ Pinia

Basic example

นี่เป็นตัวอย่างในการใช้ของ Pinia ในเบื้องต้น

// stores/counter.js
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
state: () => {
return { count: 0 }
},
// could also be defined as
// state: () => ({ count: 0 })
actions: {
increment() {
this.count++
},
},
})

และเราสามารถใช้ใน Component แบบนี้ได้

<script setup>
import { useCounterStore } from '@/stores/counter'

const counter = useCounterStore()

counter.count++
// with autocompletion ✨
counter.$patch({ count: counter.count + 1 })
// or using an action instead
counter.increment()
</script>

<template>
<!-- Access the state directly from the store -->
<div>Current Count: {{ counter.count }}</div>
</template>

เราสามารถใช้ในรูปแบบของ Function ได้

export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
function increment() {
count.value++
}

return { count, increment }
})

ถ้าคุณยังไม่ได้เข้าใจการใช้กับ setup() และ Composition API ไม่ต้องห่วง เราสามารถใช้กับ **map helpers like Vuex** เราสามารถประกาศ stores แต่ใช้ mapStores()mapState(), หรือ mapActions() ได้

const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
getters: {
double: (state) => state.count * 2,
},
actions: {
increment() {
this.count++
},
},
})

const useUserStore = defineStore('user', {
// ...
})

export default defineComponent({
computed: {
// other computed properties
// ...
// gives access to this.counterStore and this.userStore
...mapStores(useCounterStore, useUserStore),
// gives read access to this.count and this.double
...mapState(useCounterStore, ['count', 'double']),
},
methods: {
// gives access to this.increment()
...mapActions(useCounterStore, ['increment']),
},
})

Official Course

Official Course ของ Pinia คือ **Mastering Pinia** โดยสร้างจากคนทำ Pinia โดยจะครอบคลุมทุกอย่างตั้งแต่พื้นฐานไปถึงขั้นสูง เช่น plugins, testing, และ server-side rendering. เป็นทางที่ดีที่สุดในการเรียนรู้การใช้ Pinia****

A more realistic example

นี่เป็นตัวอย่างการใช้งาน Pinia อีกนิดหน่อย

import { defineStore } from 'pinia'

export const useTodos = defineStore('todos', {
state: () => ({
/** @type {{ text: string, id: number, isFinished: boolean }[]} */
todos: [],
/** @type {'all' | 'finished' | 'unfinished'} */
filter: 'all',
// type will be automatically inferred to number
nextId: 0,
}),
getters: {
finishedTodos(state) {
// autocompletion! ✨
return state.todos.filter((todo) => todo.isFinished)
},
unfinishedTodos(state) {
return state.todos.filter((todo) => !todo.isFinished)
},
/**
* @returns {{ text: string, id: number, isFinished: boolean }[]}
*/
filteredTodos(state) {
if (this.filter === 'finished') {
// call other getters with autocompletion ✨
return this.finishedTodos
} else if (this.filter === 'unfinished') {
return this.unfinishedTodos
}
return this.todos
},
},
actions: {
// any amount of arguments, return a promise or not
addTodo(text) {
// you can directly mutate the state
this.todos.push({ text, id: this.nextId++, isFinished: false })
},
},
})

Getting Started

ติดตั้ง pinia โดยการใช้ package manager ที่คุณชอบ

yarn add pinia
# or with npm
npm install pinia

ถ้าคุณใช้ Vue CLI, คุณสามารถลองใช้ **unofficial plugin** ได้

สร้าง pinia instance และ ส่ง Pinia ของเราเข้าไปเป็น plugin

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

ถ้าคุณใช้ Vue 2 คุณต้อง โหลด plugin และ สร้าง pinia ที่ root ของ Application

import { createPinia, PiniaVuePlugin } from 'pinia'

Vue.use(PiniaVuePlugin)
const pinia = createPinia()

new Vue({
el: '#app',
// other options...
// ...
// note the same `pinia` instance can be used across multiple Vue apps on
// the same page
pinia,
})

What is a Store?

Store เป็นตัวที่ใช้เก็บ State และ Logic ไว้ ที่จะไม่ผูกพันธ์กับ Component ของคุณ พูดให้เข้าใจง่าย มันจะเก็บ global state ไว้นั่นเอง. มันทำตัวคล้าย ๆ Component ที่ทุก ๆ Component จะสามารถเรียกใช้ หรือ เขียนค่าทับมันได้ โดย concepts 3 อย่าง ของมันคือ stategetters และ **actions** และมันจะถูกเขียนออกมาเป็น datacomputed และ methods ใน Component.

When should I use a Store

Store จะเก็บข้อมูลที่สามารถเข้าถึงได้ทั่วทั้ง Application ซึ่งรวมถึงข้อมูลที่ถูกใช้งานในหลายส่วน เช่น ข้อมูล User ที่แสดงใน navbar หรือข้อมูลที่ต้องคงอยู่ระหว่างหน้า เช่น ฟอร์มหลายขั้นตอนที่ซับซ้อน

ในทางกลับกัน คุณควรหลีกเลี่ยงการใส่ข้อมูล ที่ไม่ได้แชร์กับ Component อื่น ๆ เช่น การกำหนดการมองเห็นของ Element ที่จะแสดงผลแค่หน้า ๆ เดียว

ไม่ใช่ทุก Application จะต้องการ State ที่แชร์ได้ทั่ว Application แต่หาก Application ของคุณต้องการ State แบบนี้ การใช้ Pinia จะช่วยให้คุณทำงานได้ง่ายขึ้น

When should I not use a Store

บางครั้ง เมื่อคุณรู้สึกว่าคุณใช้งาน store มากเกินไป คุณอาจจะต้องพิจารณาหน้าที่ของ Store ของคุณ เช่น ถ้า logic บางอย่างได้ใช้แค่กับ Component ตัวเดียว มันก็ไม่ควรใช้กับ Store


Defining a Store

ก่อนที่เราจะเข้า concepts หลัก เราต้องเรียนรู้การเข้าถึง defineStore() และ ต้องใช้ unique name, ส่งเข้าไปเป็น Argument แรก

import { defineStore } from 'pinia'

// You can name the return value of `defineStore()` anything you want,
// but it's best to use the name of the store and surround it with `use`
// and `Store` (e.g. `useUserStore`, `useCartStore`, `useProductStore`)
// the first argument is a unique id of the store across your application
export const useAlertsStore = defineStore('alerts', {
// other options...
})

โดยชื่อนี้ จะถูกอ้างอิงเป็น Id และใช้กับ Pinia เพื่อเชื่อมต่อกับ devtools และการตั้งชื่อ ควรจะตั้งเป็น use... เพื่อทำความเข้าใจได้ง่ายขึ้น

defineStore() จะรับค่าเข้ามาสองตัว โดยตัวที่สอง จะเป็น function ที่ใช้ในการทำงาน

Option Stores

เหมือนกับ Vue's Options API เราสามารถส่ง Option Object ด้วย stateactions, และ  getters  properties ได้

export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0, name: 'Eduardo' }),
getters: {
doubleCount: (state) => state.count * 2,
},
actions: {
increment() {
this.count++
},
},
})

คุณสามารถคิดว่า state เป็น data ของ Store, getters เป็น computed properties ของ store, และ actions เป็น methods.

Setup Stores

นี่เป็นหนึ่งในวิธีการ Setup Store ของเรา เหมือนกับ Vue Composition API's setup function, เราสามารถส่ง function ที่เป็น reactive properties และ methods และ returns ค่า ออกมาเป็น object ด้วย properties และ methods ที่เราต้องการ.

export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const name = ref('Eduardo')
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}

return { count, name, doubleCount, increment }
})

ใน Setup Stores:

  • ref() มาเป็น state properties
  • computed() มาเป็น getters
  • function() มาเป็น actions

โปรดจำไว้ว่า คุณต้อง return Properties ทุกตัวออกมาที่อยู่ภายใน Store ในอีกนัยหนึ่ง คุณต้อง ห้ามมี **private state properties in stores,** ไม่ return property ออกมาทุกตัว หรือทำให้มันเป็นข้อมูลที่ readonly เพราะว่ามันจะทำลาย SSR, devtools, และ other plugins

Setup stores ก็สามารถใช้งานกับ globally provided properties เช่น Router หรือ Route ได้. Property ทุกตัวใน Application สามารถเข้าถึง Store ได้ โดยใช้ inject() เหมือนกับ components

import { inject } from 'vue'
import { useRoute } from 'vue-router'
import { defineStore } from 'pinia'

export const useSearchFilters = defineStore('search-filters', () => {
const route = useRoute()
// this assumes `app.provide('appProvided', 'value')` was called
const appProvided = inject('appProvided')

// ...

return {
// ...
}
})

Using the store

เราประกาศ Store เพราะว่า store จะไม่ได้ถูกสร้างจนกว่า use...Store() จะถูกเรียก ใน Component <script setup> (หรือใน setup())

<script setup>
import { useCounterStore } from '@/stores/counter'

// access the `store` variable anywhere in the component ✨
const store = useCounterStore()
</script>

เราสามารถประกาศ Store เท่าไหร่ก็ได้ และเราควรประกาศแต่ละตัวในไฟล์ที่ต่างกัน เพื่อเราจะได้แยกโค้ดของเราออกจากกันให้ชัดเจน

เมื่อเราสร้างเสร็จแล้ว เราสามารถเข้าถึง property ใน stategetters, และ actions โดยตรงได้เลย โดยเราจะลงรายละเอียดในบทต่อ ๆ ไป

จำไว้ว่า store เป็น Object ที่คลุม reactive ไว้ นั่นหมายความว่า เราไม่จำเป็นต้องเรียน .value หลัง getter แต่เหมือน Props ใน setup เราไม่สามารถ destructure มันได้

<script setup>
import { useCounterStore } from '@/stores/counter'
import { computed } from 'vue'

const store = useCounterStore()
// ❌ This won't work because it breaks reactivity
// it's the same as destructuring from `props`
const { name, doubleCount } = store
name // will always be "Eduardo"
doubleCount // will always be 0

setTimeout(() => {
store.increment()
}, 1000)

// ✅ this one will be reactive
// 💡 but you could also just use `store.doubleCount` directly
const doubleValue = computed(() => store.doubleCount)
</script>

Destructuring from a Store

ในการแยก Properties แต่ละตัวออกมาจาก Store โดยทำให้มันยัง reactivity อยู่ เราต้องใช้ storeToRefs(). มันจะสร้าง refs สำหรับ reactive property ทุกตัว และเราสามารถ destructure actions จาก Store ได้โดยตรงเลย

<script setup>
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'

const store = useCounterStore()
// `name` and `doubleCount` are reactive refs
// This will also extract refs for properties added by plugins
// but skip any action or non reactive (non ref/reactive) property
const { name, doubleCount } = storeToRefs(store)
// the increment action can just be destructured
const { increment } = store
</script>

State

state เป็นจุดศูนย์กลางของ Store เพราะว่ามันใช้เก็บข้อมูลของเรา. โดยปกติแล้ว เราจะประกาศ State ของ Pinia มาเป็น Function ที่จะ return ค่าเริ่มต้นของมันออกมา เพื่อให้มันก็สามารถทำงานได้ทั้ง Server และ Client

import { defineStore } from 'pinia'

export const useStore = defineStore('storeId', {
// arrow function recommended for full type inference
state: () => {
return {
// all these properties will have their type inferred automatically
count: 0,
name: 'Eduardo',
isAdmin: true,
items: [],
hasChanged: true,
}
},
})

TypeScript

เราไม่จำเป็นต้องทำอะไรมากเพื่อให้ State ของคุณ รองรับ TypeScript: เพียงแค่เปิดใช้งานตัวเลือก strict หรืออย่างน้อย noImplicitThis และ Pinia จะทำการอนุมานประเภท (type inference) ของ State ของคุณโดยอัตโนมัติ. อย่างไรก็ตาม, มีบางกรณีที่คุณควรช่วยกำหนดประเภทด้วยการแปลง (casting) บางอย่าง เช่น:

export const useUserStore = defineStore('user', {
state: () => {
return {
// for initially empty lists
userList: [] as UserInfo[],
// for data that is not yet loaded
user: null as UserInfo | null,
}
},
})

interface UserInfo {
name: string
age: number
}

ถ้าคุณต้องการ คุณสามารถประกาศ State ด้วย interface และ return ค่าออกมาเป็น state():

interface State {
userList: UserInfo[]
user: UserInfo | null
}

export const useUserStore = defineStore('user', {
state: (): State => {
return {
userList: [],
user: null,
}
},
})

interface UserInfo {
name: string
age: number
}

Accessing the state

โดยปกติ เราสามารถอ่านค่าและเขียนค่าของ State โดยเข้าถึงมันผ่าน store

const store = useStore()

store.count++

จำไว้ว่า เราไม่สามารถเพิ่ม Property ใหม่เข้าไปใน state() ถ้าเราไม่ประกาศมันก่อน มันต้องเก็บค่าเริ่มต้นไว้ ตัวอย่างเช่น เราไม่สามารถ store.secondCount = 2 ภ้า secondCount ไม่ได้ถูกประกาศใน State state().

Resetting the state

ใน **Option Stores** เราสามารถ reset ไปเป็นค่าเริ่มต้นของมันได้ โดยเรียกใช้ $reset() method ใน Store

const store = useStore()

store.$reset()

การทำงานของมัน มันจะสร้าง state() ใหม่ของตัวเก่าขึ้นมาและทับค่าเดิม

ใน Setup Stores, เราจำเป็นต้องสร้าง $reset() method ด้วยตัวเอง

export const useCounterStore = defineStore('counter', () => {
const count = ref(0)

function $reset() {
count.value = 0
}

return { count, $reset }
})

Usage with the Options API

ตามตัวอย่างด้านล่าง เราสามารถสร้าง store ได้ตามนี้ โดยใช้ Options API

// Example File Path:
// ./src/stores/counter.js

import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
}),
})

ถ้าคุณไม่ได้ใช้ Composition API และ คุณใช้ computedmethods คุณสามารถใช้ mapState() Properties เป็น readonly computed properties:

import { mapState } from 'pinia'
import { useCounterStore } from '../stores/counter'

export default {
computed: {
// gives access to this.count inside the component
// same as reading from store.count
...mapState(useCounterStore, ['count'])
// same as above but registers it as this.myOwnName
...mapState(useCounterStore, {
myOwnName: 'count',
// you can also write a function that gets access to the store
double: store => store.count * 2,
// it can have access to `this` but it won't be typed correctly...
magicValue(store) {
return store.someGetter + this.count + this.double
},
}),
},
}

Modifiable state

ถ้าคุณต้องการเขียนค่าของ State คุณสามารถใช้ mapWritableState() แทนได้

import { mapWritableState } from 'pinia'
import { useCounterStore } from '../stores/counter'

export default {
computed: {
// gives access to this.count inside the component and allows setting it
// this.count++
// same as reading from store.count
...mapWritableState(useCounterStore, ['count']),
// same as above but registers it as this.myOwnName
...mapWritableState(useCounterStore, {
myOwnName: 'count',
}),
},
}

Mutating the state

นอกจากใช้ store.count++ โดยตรง เราสามารถใช้  $patch method ในการแปลงค่าได้เหมือนกัน มันอนุญาตให้คุณเปลี่ยนค่าหลาย ๆ ครั้งในเวลเดียวกันได้ ในการเปลี่ยนค่าบางส่วนของ state

store.$patch({
count: store.count + 1,
age: 120,
name: 'DIO',
})

อย่างไรก็ตาม มันเป็นการยากในการใช้ syntax นี้: ในการดัดแปลง (เช่น pushing, removing, splicing an element จาก array) ต้องให้เราสร้าง collection ใหม่ขึ้นมา เพราะว่าสิ่งนี้ $patch method ก็สามารถรับ function ในการทำการเปลี่ยนแปลงข้อมูลประเภทนี้ได้

store.$patch((state) => {
state.items.push({ name: 'shoes', quantity: 1 })
state.hasChanged = true
})

ความแตกต่างคือ $patch() อนุญาตให้คุณจับกลุ่มการเปลี่ยนแปลงหลาย ๆ ครั้งเป็นครั้งเดียวได้

Replacing the state

คุณไม่สามารถทับค่าเก่าของ State ได้ เพราะว่ามันจะไปหยุด reactivity อย่างไรก็ตาม คุณสามารถ patch มันได้

// this doesn't actually replace `$state`
store.$state = { count: 24 }
// it internally calls `$patch()`:
store.$patch({ count: 24 })

Subscribing to the state

คุณสามารถตรวจจับการเปลี่ยนแปลงของ State ดู โดยการใช้ $subscribe() ของ Store. ในการใช้ $subscribe() แทน watch() เพราะว่า subscriptions จะถูก trigger แค่ครั้งเดียวหลังจากมัน patches

cartStore.$subscribe((mutation, state) => {
// import { MutationType } from 'pinia'
mutation.type // 'direct' | 'patch object' | 'patch function'
// same as cartStore.$id
mutation.storeId // 'cart'
// only available with mutation.type === 'patch object'
mutation.payload // patch object passed to cartStore.$patch()

// persist the whole state to the local storage whenever it changes
localStorage.setItem('cart', JSON.stringify(state))
})

Flush timing

ภายใต้การทำงานของ $subscribe() มันใช้ watch() function. คุณสามารถส่ง Option ที่เหมือนกับส่งให้ watch() ได้. นี่มีประโยชน์เมื่อเราต้องการ trigger subscriptions หลักจาก state แต่ละตัวเปลี่ยนค่า

Detaching subscriptions

โดยปกติ state subscriptions จะผูกพันธ์กับ component ที่มันถูกเพิ่มเข้าไป หมายความว่า เมื่อมัน unmounted มันจะยกเลิกการติดตาม State แต่ถ้าเราต้องการให้มันติดตามหลังจาก unmounted แล้ว เราสามารถใส่ { detached: true }  เป็น Argument ตัวที่สองได้ เพื่อติดตาม State จาก component ปัจจุบัน

<script setup>
const someStore = useSomeStore()

// this subscription will be kept even after the component is unmounted
someStore.$subscribe(callback, { detached: true })
</script>

Getters

Getters ใช้ในการ computed values สำหรับ state ของ Store มันสามารถ defined getters property ใน defineStore() ได้ มันจะรับ state มาเป็น Parameter แรก และส่งค่าออกไปโดยใช้ Arrow Function

export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
}),
getters: {
doubleCount: (state) => state.count * 2,
},
})

getters จะทำงานกับ State เป็นส่วนใหญ่ อย่างไรก็ตาม มันอาจจะถูกใช้โดย Getter ตัวอื่นได้ เพราะว่า เราสามารถเข้าถึง Property ทุกตัวผ่าน this แต่จำเป็นจะต้องกำหนด type เมื่อเราใช้ TypeScript

นี่เป็นปกติของ TypeScript อยู่แล้ว โดยมันจะไม่ส่งผลต่อ getter

export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
}),
getters: {
// automatically infers the return type as a number
doubleCount(state) {
return state.count * 2
},
// the return type **must** be explicitly set
doublePlusOne(): number {
// autocompletion and typings for the whole store ✨
return this.doubleCount + 1
},
},
})

คุณสามารถเข้าถึง getter ได้จาก Store ได้โดยตรง

<script setup>
import { useCounterStore } from './counterStore'

const store = useCounterStore()
</script>

<template>
<p>Double count is {{ store.doubleCount }}</p>
</template>

Accessing other getters

เราสามารถใช้ getters หลาย ๆ ตัวได้ โดยจะเข้าถึง getter ผ่าน this ในกรณีที่ใช้ TypeScript เราจำเป็นต้องกำหนด type ด้วย

export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
}),
getters: {
doubleCount(state) {
return state.count * 2
},
doubleCountPlusOne(): number {
return this.doubleCount + 1
},
},
})

JavaScript

// You can use JSDoc (https://jsdoc.app/tags-returns.html) in JavaScript
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
}),
getters: {
// type is automatically inferred because we are not using `this`
doubleCount: (state) => state.count * 2,
// here we need to add the type ourselves (using JSDoc in JS). We can also
// use this to document the getter
/**
* Returns the count value times two plus one.
*
* @returns {number}
*/
doubleCountPlusOne() {
// autocompletion ✨
return this.doubleCount + 1
},
},
})

Passing arguments to getters

Getters เป็นแค่ computed properties, ดังนั้นมันจะไม่สามารถส่ง argument เข้าไปได้ อย่างไรก็ตาม คุณสามารถส่ง function ออกมาจาก getter ได้

export const useStore = defineStore('main', {
getters: {
getUserById: (state) => {
return (userId) => state.users.find((user) => user.id === userId)
},
},
})

การใช้งานใน Component

<script setup>
import { storeToRefs } from 'pinia'
import { useUserListStore } from './store'

const userList = useUserListStore()
const { getUserById } = storeToRefs(userList)
// note you will have to use `getUserById.value` to access
// the function within the <script setup>
</script>

<template>
<p>User 2: {{ getUserById(2) }}</p>
</template>

โปรดทราบว่า เมื่อคุณทำแบบนี้ getters จะไม่ถูก cached อีกต่อไป แต่จะกลายเป็น function ที่คุณเรียกใช้โดยตรงแทน อย่างไรก็ตาม คุณสามารถ cached ผลลัพธ์บางอย่างภายใน getter ได้ด้วยตัวเอง ซึ่งแม้จะไม่ใช่เรื่องที่พบบ่อยนัก แต่สามารถเพิ่มประสิทธิภาพการทำงานได้ในบางกรณี:

export const useStore = defineStore('main', {
getters: {
getActiveUserById(state) {
const activeUsers = state.users.filter((user) => user.active)
return (userId) => activeUsers.find((user) => user.id === userId)
},
},
})

Accessing other stores getters

ในการใช้ getters เราสามารถใช้ภายใน getter ได้โดยตรงเลย

import { useOtherStore } from './other-store'

export const useStore = defineStore('main', {
state: () => ({
// ...
}),
getters: {
otherGetter(state) {
const otherStore = useOtherStore()
return state.localData + otherStore.data
},
},
})

Usage with setup()

เราสามารถเข้าถึง getter เป็น Property ได้ (เหมือนกับเป็น State อีกตัว)

<script setup>
const store = useCounterStore()

store.count = 3
store.doubleCount // 6
</script>

Usage with the Options API

จากตัวอย่าง เราจะสมมติว่า store ถูกสร้างขึ้นมาแล้ว

// Example File Path:
// ./src/stores/counter.js

import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
}),
getters: {
doubleCount(state) {
return state.count * 2
},
},
})

With setup()

ในขณะที่ Composition API ไม่สามารถใช้ได้กับทุกคน,  setup() hook สามารถทำให้มันสามารถใช้งานได้ง่ายขึ้น ในการใช้งานกับ Options API. เราไม่ต้องใช้ helper functions เพิ่ม

<script>
import { useCounterStore } from '../stores/counter'

export default defineComponent({
setup() {
const counterStore = useCounterStore()

// **only return the whole store** instead of destructuring
return { counterStore }
},
computed: {
quadrupleCounter() {
return this.counterStore.doubleCount * 2
},
},
})
</script>

Without setup()

เราสามารถใช้ mapState() function ในการสร้าง getter ได้เหมือนกัน

import { mapState } from 'pinia'
import { useCounterStore } from '../stores/counter'

export default {
computed: {
// gives access to this.doubleCount inside the component
// same as reading from store.doubleCount
...mapState(useCounterStore, ['doubleCount']),
// same as above but registers it as this.myOwnName
...mapState(useCounterStore, {
myOwnName: 'doubleCount',
// you can also write a function that gets access to the store
double: (store) => store.doubleCount,
}),
},
}

Actions

Actions เป็นเหมือน method ใน Component นี้ มันสามารถประกาศเป็น actions property ใน defineStore()  และมันใช้ได้ดีมากกับการเขียน logic

export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
}),
actions: {
// since we rely on `this`, we cannot use an arrow function
increment() {
this.count++
},
randomizeCounter() {
this.count = Math.round(100 * Math.random())
},
},
})

เหมือนกับ getters, action สามารถเข้าถึง store ทั้งหมดได้ผ่าน this . ไม่เหมือนกับการใช้ getters, actions สามารถเป็น asynchronous ได้ เราสามารถใช้ await ได้

import { mande } from 'mande'

const api = mande('/api/users')

export const useUsers = defineStore('users', {
state: () => ({
userData: null,
// ...
}),

actions: {
async registerUser(login, password) {
try {
this.userData = await api.post({ login, password })
showTooltip(`Welcome back ${this.userData.name}!`)
} catch (error) {
showTooltip(error)
// let the form component display the error
return error
}
},
},
})

เราสามารถกำหนดค่าทุกอย่างได้อย่างอิสระ และ return ค่าทุกอย่างได้เมื่อเรียกใช้ action

Actions จะเป็นเหมือนกับ functions และ method ธรรมดา

<script setup>
const store = useCounterStore()
// call the action as a method of the store
store.randomizeCounter()
</script>

<template>
<!-- Even on the template -->
<button @click="store.randomizeCounter()">Randomize</button>
</template>

Accessing other stores actions

ในการใช้งาน Store ตัวอื่น ๆ เราสามารถเข้าถึงได้โดยตรงใน action

import { useAuthStore } from './auth-store'

export const useSettingsStore = defineStore('settings', {
state: () => ({
preferences: null,
// ...
}),
actions: {
async fetchUserPreferences() {
const auth = useAuthStore()
if (auth.isAuthenticated) {
this.preferences = await fetchPreferences()
} else {
throw new Error('User must be authenticated')
}
},
},
})

Usage with the Options API

สำหรับ examples ด้านล่าง เราจะสมมติว่า store ถูกสร้างแล้ว

// Example File Path:
// ./src/stores/counter.js

import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
}),
actions: {
increment() {
this.count++
},
},
})

With setup()

ในขณะที่ Composition API ไม่ได้เหมาะกับทุกคน setup() สามารถทำให้ Pinia สามารถใช้งานได้ง่ายขึ้น และถ้าเราใช้ Option API เราไม่จำเป็นต้องใช้ map helper function

<script>
import { useCounterStore } from '../stores/counter'

export default defineComponent({
setup() {
const counterStore = useCounterStore()

return { counterStore }
},
methods: {
incrementAndPrint() {
this.counterStore.increment()
console.log('New Count:', this.counterStore.count)
},
},
})
</script>

Without setup()

ถ้าเราไม่ต้องการใช้ Composition API เลย เราสามารถใช้ mapActions() ในการสร้าง actions ได้

import { mapActions } from 'pinia'
import { useCounterStore } from '../stores/counter'

export default {
methods: {
// gives access to this.increment() inside the component
// same as calling from store.increment()
...mapActions(useCounterStore, ['increment']),
// same as above but registers it as this.myOwnName()
...mapActions(useCounterStore, { myOwnName: 'increment' }),
},
}

Subscribing to actions

คุณสามารถสังเกตการทำงานของ actions และผลลัพธ์ของมันได้ด้วย store.$onAction(). callback function ที่ส่งให้กับ $onAction() จะถูกเรียกใช้งานก่อนที่ action นั้นจะเริ่มทำงาน

  • after: ใช้สำหรับจัดการ promises และจะเรียกใช้ function หลังจากที่ action ทำงานสำเร็จแล้ว
  • onError: ใช้สำหรับเรียก function เมื่อ action throw Error หรือถูก reject

ฟีเจอร์เหล่านี้มีประโยชน์สำหรับการติดตามข้อผิดพลาดขณะรันไทม์

ซึ่งคล้ายกับ **this tip in the Vue docs** ของ Vue

const unsubscribe = someStore.$onAction(
({
name, // name of the action
store, // store instance, same as `someStore`
args, // array of parameters passed to the action
after, // hook after the action returns or resolves
onError, // hook if the action throws or rejects
}) => {
// a shared variable for this specific action call
const startTime = Date.now()
// this will trigger before an action on `store` is executed
console.log(`Start "${name}" with params [${args.join(', ')}].`)

// this will trigger if the action succeeds and after it has fully run.
// it waits for any returned promised
after((result) => {
console.log(
`Finished "${name}" after ${
Date.now() - startTime
}ms.\nResult: ${result}.`
)
})

// this will trigger if the action throws or returns a promise that rejects
onError((error) => {
console.warn(
`Failed "${name}" after ${Date.now() - startTime}ms.\nError: ${error}.`
)
})
}
)

// manually remove the listener
unsubscribe()

โดยปกติแล้ว action subscriptions จะผูกพันธ์กับ Component ตรงที่มันถูกเพิ่มเข้าไป หมายความว่า มันจะถูกลบออกเมื่อ Component unmounted ถ้าเราต้องการจะให้มันทำงานได้อยู่ เราสามารถใส่ true เป็น Argument ตัวที่ 2 ได้ เพื่อติดตามการทำงานของ Action ต่อไป

<script setup>
const someStore = useSomeStore()

// this subscription will be kept even after the component is unmounted
someStore.$onAction(callback, true)
</script>

Plugins

Pinia stores สามารถขยายประสิทธิภาพการทำงานโดยทำให้เป็น low level API ได้. นี่เป็นลิสต์ที่คุณสามารถทำได้

  • เพิ่ม Properties เข้าไปใน Stre
  • เพิ่ม options เมื่อประกาศ Store
  • เพิ่ม methods เข้าไปใน stores
  • ห่อหุ้ม (wrap) method ที่มีอยู่แล้ว ใช้เพื่อปรับแต่งหรือเพิ่ม function เสริมให้กับ method เดิม
  • ดักจับ actions และผลลัพธ์ของมัน: สามารถตรวจสอบและเปลี่ยนแปลงพฤติกรรมของ action ได้
  • สร้างผลข้างเคียง (side effects) เช่นการเชื่อมต่อกับ Local Storage: เก็บสถานะของ store ไว้ใน Local Storage เพื่อความยืดหยุ่นของการใช้งาน
  • ใช้เฉพาะกับ stores บางตัวเท่านั้น: ควบคุมการเปลี่ยนแปลงให้เกิดขึ้นเฉพาะกับ store ที่กำหนด

Plugins จะสามารถใช้ได้โดย Pinia โดยการเรียกใช้ pinia.use() พื้นฐานที่สุดในการเพิ่ม Property เข้าไปที่ Stores ทุกตัว

import { createPinia } from 'pinia'

// add a property named `secret` to every store that is created
// after this plugin is installed this could be in a different file
function SecretPiniaPlugin() {
return { secret: 'the cake is a lie' }
}

const pinia = createPinia()
// give the plugin to pinia
pinia.use(SecretPiniaPlugin)

// in another file
const store = useStore()
store.secret // 'the cake is a lie'

Introduction

Pinia plugin เป็น function ที่ไม่จำเป็นต้อง returns properties เพื่อเพิ่ม properties เข้าไปที่ Store. มันจะรับ optional argument มา 1 ตัว

export function myPiniaPlugin(context) {
context.pinia // the pinia created with `createPinia()`
context.app // the current app created with `createApp()` (Vue 3 only)
context.store // the store the plugin is augmenting
context.options // the options object defining the store passed to `defineStore()`
// ...
}

function นี้ จะส่งเข้าไปที่ pinia โดยใช้ pinia.use():

pinia.use(myPiniaPlugin)

Plugins สามารถใช้งานได้กับแค่ Store ที่สร้างหลัง plugins

Augmenting a Store

เราสามารถเพิ่ม properties เข้าไปที่ Store ทุกตัวได้ โดยทำตามขั้นตอนนี้

pinia.use(() => ({ hello: 'world' }))

คุณสามารถ set property โดยตรงใน store ได้ แต่ถ้าเป็นไปได้ เราควรใช้ return เพื่อให้มันสามารถติดตามโดย devtools ได้

pinia.use(({ store }) => {
store.hello = 'world'
})

property ทุกตัวที่ return โดย plugin จะถูกติดตามโดย devtools ดังนั้น ถ้าเราต้องการจะทำให้ hello สามารถมองเห็นได้จาก devtools ทำให้มั่นใจว่าเราเพิ่มมันเข้าไปใน store._customProperties ใน dev mode ถ้าเราต้องการจะ debug ใน devtools

// from the example above
pinia.use(({ store }) => {
store.hello = 'world'
// make sure your bundler handles this. webpack and vite should do it by default
if (process.env.NODE_ENV === 'development') {
// add any keys you set on the store
store._customProperties.add('hello')
}
})

จำไว้ว่า store ทุกตัวถูกคลุมไว้โดย **reactive ,** และ ref จะถูก unwrapping โดยอัตโนมัติ (ref()computed())

const sharedRef = ref('shared')
pinia.use(({ store }) => {
// each store has its individual `hello` property
store.hello = ref('secret')
// it gets automatically unwrapped
store.hello // 'secret'

// all stores are sharing the value `shared` property
store.shared = sharedRef
store.shared // 'shared'
})

นี่คือสาเหตุ ที่เราสามารถเข้าถึง computed properties ทุกตัว โดยไม่ใช้ .value ได้

Adding new state

ถ้าเราต้องการที่จะเพิ่ม State ใหม่เข้าไปที่ Store หรือ properties ที่เราต้องใช้ขณะ hydration, เราต้องเพิ่มมันไปใน 2 ที่

  • บน store และจะเข้าถึงได้โดย store.myState
  • บน store.$state และจะใช้ได้โดย devtools และ ใช้ได้โดย SSR

นอกจากนี้ คุณจะต้องใช้ ref() (หรือ API แบบ reactive อื่น ๆ) เพื่อทำให้ค่าของ State แชร์ได้ระหว่างการเข้าถึงในส่วนต่าง ๆ

import { toRef, ref } from 'vue'

pinia.use(({ store }) => {
// to correctly handle SSR, we need to make sure we are not overriding an
// existing value
if (!store.$state.hasOwnProperty('hasError')) {
// hasError is defined within the plugin, so each store has their individual
// state property
const hasError = ref(false)
// setting the variable on `$state`, allows it be serialized during SSR
store.$state.hasError = hasError
}
// we need to transfer the ref from the state to the store, this way
// both accesses: store.hasError and store.$state.hasError will work
// and share the same variable
// See https://vuejs.org/api/reactivity-utilities.html#toref
store.hasError = toRef(store.$state, 'hasError')

// in this case it's better not to return `hasError` since it
// will be displayed in the `state` section in the devtools
// anyway and if we return it, devtools will display it twice.
})

โปรดทราบว่า การเปลี่ยนแปลงหรือการเพิ่ม State ที่เกิดขึ้นภายใน plugin จะเกิดขึ้นก่อนที่ store จะถูกเปิดใช้งาน (active) ดังนั้นมันจะไม่ trigger subscriptions

Resetting state added in plugins

โดยปกติแล้ว $reset() จะไม่ reset state ที่เพิ่มโดย plugins แต่คุณสามารถเขียนค่าทับมันได้ มันสามารถ reset state ที่คุณเพิ่มได้

import { toRef, ref } from 'vue'

pinia.use(({ store }) => {
// this is the same code as above for reference
if (!store.$state.hasOwnProperty('hasError')) {
const hasError = ref(false)
store.$state.hasError = hasError
}
store.hasError = toRef(store.$state, 'hasError')

// make sure to set the context (`this`) to the store
const originalReset = store.$reset.bind(store)

// override the $reset function
return {
$reset() {
originalReset()
store.hasError = false
},
}
})

Adding new external properties

เมื่อเราเพิ่ม Properties ภายนอก, เช่น เพิ่ม class ที่มาจาก library ตัวอื่น หรือ สิ่งง่าย ๆ ที่ไม่ได้เป็น reactive ควรจะถูกหุ้มด้วย markRaw() ก่อนจะส่งเข้าไปที่ Pinia นี่เป็นตัวอย่างในการเพิ่มเข้าไปที่ทุก Store

import { markRaw } from 'vue'
// adapt this based on where your router is
import { router } from './router'

pinia.use(({ store }) => {
store.router = markRaw(router)
})

Calling $subscribe inside plugins

เราสามารถใช้ store.$subscribe และ store.$onAction ใน plugins ได้ด้วยเหมือนกัน:

pinia.use(({ store }) => {
store.$subscribe(() => {
// react to store changes
})
store.$onAction(() => {
// react to store actions
})
})

Adding new options

เราสามารถสร้าง options ใหม่มาได้ เมื่อเราประกาศ stores. เพื่อให้ Plugins สามารถใช้งานได้ ตัวอย่างเช่น เราสามารถสร้าง debounce ที่สามารถให้เราสามารถหน่วงเวลาการใช้งาน action ได้

defineStore('search', {
actions: {
searchContacts() {
// ...
},
},

// this will be read by a plugin later on
debounce: {
// debounce the action searchContacts by 300ms
searchContacts: 300,
},
})

plugin สามารถอ่าน Option ใน store เพื่อคลุม actions และแทนที่ action เดิมได้

// use any debounce library
import debounce from 'lodash/debounce'

pinia.use(({ options, store }) => {
if (options.debounce) {
// we are overriding the actions with new ones
return Object.keys(options.debounce).reduce((debouncedActions, action) => {
debouncedActions[action] = debounce(
store[action],
options.debounce[action]
)
return debouncedActions
}, {})
}
})

จำไว้ว่า custom options จะส่งเป็น argument ตัวที่ 3 เมื่อเราใช้ setup syntax

defineStore(
'search',
() => {
// ...
},
{
// this will be read by a plugin later on
debounce: {
// debounce the action searchContacts by 300ms
searchContacts: 300,
},
}
)

TypeScript

ทุกอย่างด้านบนสามารถทำได้โดยใช้ typing support ดังนั้น คุณไม่จำเป็นต้องใช้ any หรือ @ts-ignore.

Typing plugins

Pinia plugin สามารถเขียนได้ตามตัวอย่างด้านล่าง

import { PiniaPluginContext } from 'pinia'

export function myPiniaPlugin(context: PiniaPluginContext) {
// ...
}

Typing new store properties

เมื่อเราเพิ่ม properties ใหม่เข้าไปใน Store เราสามารถใช้ PiniaCustomProperties interface ได้

import 'pinia'
import type { Router } from 'vue-router'

declare module 'pinia' {
export interface PiniaCustomProperties {
// by using a setter we can allow both strings and refs
set hello(value: string | Ref<string>)
get hello(): string

// you can define simpler values too
simpleNumber: number

// type the router added by the plugin above (#adding-new-external-properties)
router: Router
}
}

สามารถเขียนและอ่านได้อย่างปลอดภัย

pinia.use(({ store }) => {
store.hello = 'Hola'
store.hello = ref('Hola')

store.simpleNumber = Math.random()
// @ts-expect-error: we haven't typed this correctly
store.simpleNumber = ref(Math.random())
})

PiniaCustomProperties เป็น generic type ที่อนุญาตให้เราอ้างอิงไปถึง properties ของ Store ได้
โดยในตัวอย่างนี้ เราสามารถคัดลอก Option ของ store มาเก็บไว้ใน $options

pinia.use(({ options }) => ({ $options: options }))

เราสามารถใช้ generic types ของ PiniaCustomProperties ทั้ง 4 ตัวได้

import 'pinia'

declare module 'pinia' {
export interface PiniaCustomProperties<Id, S, G, A> {
$options: {
id: Id
state?: () => S
getters?: G
actions?: A
}
}
}

Typing new state

เมื่อเราเพิ่ม state properties, เราต้องเพิ่มประเภทเข้าไปที่ PiniaCustomStateProperties แทน ซึ่งต่างจาก PiniaCustomProperties มันจะรับแค่ State

import 'pinia'

declare module 'pinia' {
export interface PiniaCustomStateProperties<S> {
hello: string
}
}

Typing new creation options

เมื่อเรา สร้าง Option สำหรับ defineStore() ใน Pinia, คุณควร extend DefineStoreOptionsBase ซึ่งแตกต่างจาก PiniaCustomProperties เพราะ DefineStoreOptionsBase จะเปิดเผย generic เพียงสองตัว ได้แก่ State และ Store type เท่านั้น ซึ่งช่วยให้คุณสามารถจำกัดสิ่งที่สามารถกำหนดได้ภายใน store เช่น การกำหนดชื่อของ actions ที่สามารถใช้ได้

import 'pinia'

declare module 'pinia' {
export interface DefineStoreOptionsBase<S, Store> {
// allow defining a number of ms for any of the actions
debounce?: Partial<Record<keyof StoreActions<Store>, number>>
}
}

Nuxt.js

เมื่อเราใช้ Pinia กับ Nuxt คุณจะต้องสร้าง Nuxt Plugin ก่อน. นี่จะให้คุณเข้าถึง pinia

// plugins/myPiniaPlugin.ts
import { PiniaPluginContext } from 'pinia'

function MyPiniaPlugin({ store }: PiniaPluginContext) {
store.$subscribe((mutation) => {
// react to store changes
console.log(`[🍍 ${mutation.storeId}]: ${mutation.type}.`)
})

// Note this has to be typed if you are using TS
return { creationTime: new Date() }
}

export default defineNuxtPlugin(({ $pinia }) => {
$pinia.use(MyPiniaPlugin)
})

Nuxt.js 2

ถ้าเราใช้ Nuxt.js 2, มันจะแตกต่างกันเล็กน้อย

// plugins/myPiniaPlugin.ts
import { PiniaPluginContext } from 'pinia'
import { Plugin } from '@nuxt/types'

function MyPiniaPlugin({ store }: PiniaPluginContext) {
store.$subscribe((mutation) => {
// react to store changes
console.log(`[🍍 ${mutation.storeId}]: ${mutation.type}.`)
})

// Note this has to be typed if you are using TS
return { creationTime: new Date() }
}

const myPlugin: Plugin = ({ $pinia }) => {
$pinia.use(MyPiniaPlugin)
}

export default myPlugin

Existing plugins

คุณสามารถตรวจจับ **Pinia plugins on GitHub** ได้


Using a store outside of a component

Pinia Store ขึ้นอยู่กับ pinia instance เพื่อแชร์ store instance เดียวกันในทุกการเรียกใช้งาน โดยส่วนใหญ่แล้ว สิ่งนี้จะทำงานได้ทันทีเพียงแค่เรียก function useStore() เช่น ใน setup() คุณไม่จำเป็นต้องทำอะไรเพิ่มเติม แต่สถานการณ์จะต่างออกไปหากอยู่นอก Component

เบื้องหลัง useStore() จะทำการ inject pinia instance ที่คุณมอบให้กับ Application ของคุณ ซึ่งหมายความว่า หากไม่สามารถ inject pinia instance โดยอัตโนมัติได้ คุณจะต้องให้ pinia instance แก่ useStore() function เอง

คุณสามารถแก้ปัญหานี้ได้หลากหลายวิธี ขึ้นอยู่กับลักษณะของแอปพลิเคชันที่คุณกำลังพัฒนา

Single Page Applications

ถ้าคุณไม่ได้ใช้ SSR, ทุกการเรียกใช้ useStore() หลังจากติดตั้ง pinia plugin ด้วย app.use(pinia) จะสามารถทำงานได้

import { useUserStore } from '@/stores/user'
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'

// ❌ fails because it's called before the pinia is created
const userStore = useUserStore()

const pinia = createPinia()
const app = createApp(App)
app.use(pinia)

// ✅ works because the pinia instance is now active
const userStore = useUserStore()

วิธีที่ง่ายที่สุดในการทำให้แน่ใจว่า useStore() จะได้รับการใช้งานหลังจากที่ pinia ถูกติดตั้งคือการเลื่อนการเรียกใช้ useStore() ไปวางภายใน function ที่แน่ใจว่าจะถูกเรียกหลังจากที่ pinia ถูกติดตั้งแล้ว

ตัวอย่างการใช้งาน store ภายใน navigation guard ของ Vue Router:

import { createRouter } from 'vue-router'
const router = createRouter({
// ...
})

// ❌ Depending on the order of imports this will fail
const store = useStore()

router.beforeEach((to, from, next) => {
// we wanted to use the store here
if (store.isLoggedIn) next()
else next('/login')
})

router.beforeEach((to) => {
// ✅ This will work because the router starts its navigation after
// the router is installed and pinia will be installed too
const store = useStore()

if (to.meta.requiresAuth && !store.isLoggedIn) return '/login'
})

SSR Apps

เมื่อเราทำงานกับ Server Side Rendering, คุณจะต้องส่ง pinia ไปที่ useStore(). นี่เป็นการป้องไม่ให้ Pinia แชร์ข้อมูลข้าม Application ได้

ในบทนี้สอน **SSR guide** นี่เป็นการอธิบายแบบง่าย ๆ