12.4 Pinia + TypeScript — 스토어 타입 추론과 actions
Pinia 기본 설정
npm install pinia
// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')
defineStore 타입 — Option Store
// stores/counter.ts
import { defineStore } from 'pinia'
// Option Store — Vuex와 유사한 방식
export const useCounterStore = defineStore('counter', {
// state: 타입 자동 추론
state: () => ({
count: 0,
name: 'counter',
}),
// getters: this를 통해 state 접근
getters: {
doubleCount: (state) => state.count * 2,
// 다른 getter 접근
doubleCountPlusOne(): number {
return this.doubleCount + 1
},
},
// actions: 비동기도 가능
actions: {
increment() {
this.count++
},
decrement() {
this.count--
},
async fetchAndSet(id: number) {
const data = await fetch(`/api/counter/${id}`).then(r => r.json())
this.count = data.value
},
},
})
defineStore 타입 — Setup Store (권장)
Composition API 스타일로, 더 유연하고 TypeScript 친화적입니다.
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
interface User {
id: string
name: string
email: string
role: 'admin' | 'user'
}
export const useUserStore = defineStore('user', () => {
// state — ref 사용
const currentUser = ref<User | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
// getters — computed 사용
const isLoggedIn = computed(() => currentUser.value !== null)
const isAdmin = computed(() => currentUser.value?.role === 'admin')
const displayName = computed(
() => currentUser.value?.name ?? '게스트'
)
// actions — 일반 함수
async function login(email: string, password: string) {
isLoading.value = true
error.value = null
try {
const user = await authService.login(email, password)
currentUser.value = user
} catch (e) {
error.value = e instanceof Error ? e.message : '로그인 실패'
throw e
} finally {
isLoading.value = false
}
}
function logout() {
currentUser.value = null
authService.logout()
}
function updateProfile(updates: Partial<Pick<User, 'name' | 'email'>>) {
if (!currentUser.value) return
currentUser.value = { ...currentUser.value, ...updates }
}
return {
// state
currentUser,
isLoading,
error,
// getters
isLoggedIn,
isAdmin,
displayName,
// actions
login,
logout,
updateProfile,
}
})
스토어 사용 패턴
컴포넌트에서 스토어 사용
<script setup lang="ts">
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
// storeToRefs: 반응성을 유지하며 비구조화 분해
const { currentUser, isLoggedIn, displayName, isLoading } = storeToRefs(userStore)
// actions은 반응형이 아니므로 직접 비구조화
const { login, logout, updateProfile } = userStore
async function handleLogin(email: string, password: string) {
await login(email, password)
}
</script>
<template>
<div>
<p v-if="isLoading">로그인 중...</p>
<div v-else-if="isLoggedIn">
<p>안녕하세요, {{ displayName }}님!</p>
<button @click="logout">로그아웃</button>
</div>
<LoginForm v-else @submit="handleLogin" />
</div>
</template>
스토어 타입 추출
// 스토어 타입 추출
import { useUserStore } from '@/stores/user'
type UserStore = ReturnType<typeof useUserStore>
type UserState = ReturnType<UserStore['$state']>
// 특정 타입만 추출
type LoginAction = UserStore['login']
스토어 간 통신
// stores/cart.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useUserStore } from './user'
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const userStore = useUserStore() // 다른 스토어 사용
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
async function checkout() {
if (!userStore.isLoggedIn) {
throw new Error('로그인이 필요합니다.')
}
await orderService.create({
userId: userStore.currentUser!.id,
items: items.value,
})
items.value = [] // 장바구니 비우기
}
return { items, total, checkout }
})
Pinia 플러그인 타입
// plugins/persistPlugin.ts
import type { PiniaPluginContext } from 'pinia'
function persistPlugin({ store }: PiniaPluginContext) {
// localStorage에서 복원
const stored = localStorage.getItem(store.$id)
if (stored) {
store.$patch(JSON.parse(stored))
}
// 변경 감지 및 저장
store.$subscribe((_, state) => {
localStorage.setItem(store.$id, JSON.stringify(state))
})
}
// main.ts에 등록
const pinia = createPinia()
pinia.use(persistPlugin)
고수 팁
1. $patch로 배치 업데이트
// 여러 상태를 한 번에 업데이트 (히스토리 하나로 기록)
store.$patch({
count: 10,
name: '새 이름',
})
// 함수 형태 (복잡한 업데이트)
store.$patch((state) => {
state.items.push(newItem)
state.total += newItem.price
})
2. $reset으로 상태 초기화 (Option Store만)
// Option Store
const store = useCounterStore()
store.$reset() // state를 초기값으로 리셋
3. $subscribe로 상태 변경 감지
const unsubscribe = store.$subscribe((mutation, state) => {
console.log('스토어 변경:', mutation.type, state)
})
// 구독 해제
unsubscribe()