State Management (Pinia)
MStore Dashboard menggunakan Pinia 3.x sebagai state management solution dengan pendekatan composition API.Overview
State Architecture
Three Levels of State
| Level | Where | When to Use |
|---|---|---|
| Global State | Pinia Store | Shared across multiple pages |
| Page State | Composable | Specific to one page |
| Component State | ref/reactive | Local to one component |
Decision Matrix
| Situation | Solution | Example |
|---|---|---|
| User authentication | Pinia Store | useAuthStore |
| Shopping cart | Pinia Store | useCartStore |
| Form data | Composable | useInventoryForm |
| Modal open/close | Component ref | const isOpen = ref(false) |
| Search/filter | Composable | useInventoryList |
| Pagination | Composable | usePagination |
Setup
Installation
Pinia sudah terinstall via@pinia/nuxt module:
Copy
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@pinia/nuxt'],
})
Persistence Plugin
Untuk persistent state, gunakanpinia-plugin-persistedstate:
Copy
// plugins/pinia.ts
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.$pinia.use(piniaPluginPersistedstate)
})
Store Patterns
Basic Store (Composition API)
Copy
// features/03_inventory/store/inventory.store.ts
import { defineStore } from 'pinia'
import { getInventoryList, createInventory } from '../api'
import type { Inventory, InventoryQueryParams } from '../types'
export const useInventoryStore = defineStore('inventory', () => {
// State
const items = ref<Inventory[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const total = ref(0)
// Getters
const hasItems = computed(() => items.value.length > 0)
const lowStockItems = computed(() =>
items.value.filter(item => item.stock < item.minStock)
)
// Actions
const fetchItems = async (params?: InventoryQueryParams) => {
loading.value = true
error.value = null
try {
const response = await getInventoryList(params)
items.value = response.data
total.value = response.meta.total
} catch (e) {
error.value = e instanceof Error ? e.message : 'Unknown error'
} finally {
loading.value = false
}
}
const addItem = async (item: Omit<Inventory, 'id'>) => {
const newItem = await createInventory(item)
items.value.push(newItem)
}
// Reset
const $reset = () => {
items.value = []
loading.value = false
error.value = null
total.value = 0
}
return {
// State
items,
loading,
error,
total,
// Getters
hasItems,
lowStockItems,
// Actions
fetchItems,
addItem,
$reset,
}
})
Auth Store with Persistence
Copy
// features/01_core/store/auth.ts
import { defineStore } from 'pinia'
import { jwtDecode } from 'jwt-decode'
import type { User, JwtClaims } from '../types'
export const useAuthStore = defineStore('auth', () => {
// State - Access token in memory only (security)
const accessToken = ref<string | null>(null)
const user = ref<User | null>(null)
const isAuthenticated = ref(false)
// Getters
const claims = computed<JwtClaims | null>(() => {
if (!accessToken.value) return null
try {
return jwtDecode<JwtClaims>(accessToken.value)
} catch {
return null
}
})
const hasRole = computed(() => (role: string) => {
return claims.value?.roles?.includes(role) ?? false
})
const hasPermission = computed(() => (permission: string) => {
return claims.value?.permissions?.includes(permission) ?? false
})
// Actions
const setToken = (token: string) => {
accessToken.value = token
isAuthenticated.value = true
}
const setUser = (userData: User) => {
user.value = userData
}
const logout = () => {
accessToken.value = null
user.value = null
isAuthenticated.value = false
}
return {
// State
accessToken,
user,
isAuthenticated,
// Getters
claims,
hasRole,
hasPermission,
// Actions
setToken,
setUser,
logout,
}
}, {
// Persist only user, not token (security)
persist: {
pick: ['user', 'isAuthenticated'],
},
})
Feature Store with API Integration
Copy
// features/05_sales/store/sales.store.ts
import { defineStore } from 'pinia'
import { useInventoryStore } from '~/features/03_inventory'
import { createTransaction } from '../api'
import type { CartItem, Transaction } from '../types'
export const useSalesStore = defineStore('sales', () => {
const inventoryStore = useInventoryStore()
// State
const cart = ref<CartItem[]>([])
const transactions = ref<Transaction[]>([])
const currentShift = ref<string | null>(null)
// Getters
const cartTotal = computed(() =>
cart.value.reduce((sum, item) => sum + (item.price * item.quantity), 0)
)
const cartItemCount = computed(() =>
cart.value.reduce((sum, item) => sum + item.quantity, 0)
)
// Actions
const addToCart = (productId: string, quantity: number = 1) => {
// Check stock availability
const product = inventoryStore.items.find(p => p.id === productId)
if (!product || product.stock < quantity) {
throw new Error('Insufficient stock')
}
const existing = cart.value.find(item => item.productId === productId)
if (existing) {
existing.quantity += quantity
} else {
cart.value.push({
productId,
name: product.name,
price: product.price,
quantity,
})
}
}
const checkout = async () => {
const transaction = await createTransaction({
items: cart.value,
total: cartTotal.value,
shiftId: currentShift.value,
})
transactions.value.push(transaction)
cart.value = []
return transaction
}
return {
cart,
transactions,
currentShift,
cartTotal,
cartItemCount,
addToCart,
checkout,
}
})
Composable Patterns
Page Composable
Copy
// features/03_inventory/composables/useInventoryList.ts
import { useInventoryStore } from '../store/inventory.store'
import type { InventoryQueryParams } from '../types'
export const useInventoryList = () => {
const store = useInventoryStore()
// Local state for page-specific logic
const searchQuery = ref('')
const currentPage = ref(1)
const pageSize = ref(20)
// Computed from store
const items = computed(() => store.items)
const loading = computed(() => store.loading)
const total = computed(() => store.total)
// Filtered items (local computation)
const filteredItems = computed(() => {
if (!searchQuery.value) return items.value
const query = searchQuery.value.toLowerCase()
return items.value.filter(item =>
item.name.toLowerCase().includes(query) ||
item.sku.toLowerCase().includes(query)
)
})
// Actions
const fetchItems = async () => {
const params: InventoryQueryParams = {
page: currentPage.value,
limit: pageSize.value,
search: searchQuery.value || undefined,
}
await store.fetchItems(params)
}
const goToPage = async (page: number) => {
currentPage.value = page
await fetchItems()
}
// Search with debounce
const debouncedSearch = useDebounceFn(() => {
currentPage.value = 1
fetchItems()
}, 300)
watch(searchQuery, () => {
debouncedSearch()
})
return {
// State
searchQuery,
currentPage,
pageSize,
// Computed
items: filteredItems,
loading,
total,
// Actions
fetchItems,
goToPage,
}
}
Form Composable
Copy
// features/03_inventory/composables/useInventoryForm.ts
import { useInventoryStore } from '../store/inventory.store'
import type { Inventory } from '../types'
export const useInventoryForm = (initialData?: Inventory) => {
const store = useInventoryStore()
// Form state
const form = reactive({
name: initialData?.name ?? '',
sku: initialData?.sku ?? '',
price: initialData?.price ?? 0,
stock: initialData?.stock ?? 0,
minStock: initialData?.minStock ?? 0,
category: initialData?.category ?? '',
})
// Validation
const errors = reactive({
name: '',
sku: '',
price: '',
})
const isValid = computed(() => {
return !Object.values(errors).some(e => e !== '')
})
// Validate
const validate = () => {
errors.name = form.name.length < 2 ? 'Name must be at least 2 characters' : ''
errors.sku = form.sku.length < 3 ? 'SKU must be at least 3 characters' : ''
errors.price = form.price <= 0 ? 'Price must be greater than 0' : ''
return isValid.value
}
// Submit
const submit = async () => {
if (!validate()) return false
try {
if (initialData?.id) {
await store.updateItem(initialData.id, form)
} else {
await store.addItem(form)
}
return true
} catch (error) {
console.error('Form submission failed:', error)
return false
}
}
// Reset
const reset = () => {
Object.assign(form, {
name: initialData?.name ?? '',
sku: initialData?.sku ?? '',
price: initialData?.price ?? 0,
stock: initialData?.stock ?? 0,
minStock: initialData?.minStock ?? 0,
category: initialData?.category ?? '',
})
Object.keys(errors).forEach(key => {
errors[key as keyof typeof errors] = ''
})
}
return {
form,
errors,
isValid,
validate,
submit,
reset,
}
}
Using in Components
In Page
Copy
<!-- app/pages/inventory/index.vue -->
<script setup lang="ts">
import { useInventoryList } from '~/features/03_inventory'
const {
items,
loading,
searchQuery,
currentPage,
total,
fetchItems,
goToPage,
} = useInventoryList()
onMounted(() => {
fetchItems()
})
</script>
<template>
<div>
<UInput v-model="searchQuery" placeholder="Search..." />
<InventoryTable
:items="items"
:loading="loading"
/>
<UPagination
:total="total"
:page="currentPage"
@update:page="goToPage"
/>
</div>
</template>
In Component
Copy
<!-- features/03_inventory/components/InventoryTable.vue -->
<script setup lang="ts">
import { useInventoryStore } from '../store/inventory.store'
defineProps<{
items: Inventory[]
loading: boolean
}>()
const store = useInventoryStore()
const deleteItem = async (id: string) => {
if (confirm('Are you sure?')) {
await store.deleteItem(id)
}
}
</script>
Persistence
Full Store Persistence
Copy
export const useSettingsStore = defineStore('settings', () => {
const theme = ref<'light' | 'dark'>('light')
const language = ref('id')
return { theme, language }
}, {
persist: true, // Persist entire store
})
Partial Persistence
Copy
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(null) // Don't persist (security)
const user = ref<User | null>(null) // Persist
return { token, user }
}, {
persist: {
pick: ['user'], // Only persist user
},
})
Custom Storage
Copy
export const useCartStore = defineStore('cart', () => {
// ...
}, {
persist: {
storage: sessionStorage, // Use sessionStorage instead
},
})
Best Practices
Store Organization
Store Organization
- One store per domain
- Keep stores focused and small
- Use composables for page-specific logic
- Use computed for derived state
Security
Security
- Never persist sensitive data (tokens, passwords)
- Use memory-only for access tokens
- Validate data before storing
- Clear sensitive data on logout
Performance
Performance
- Use
shallowReffor large arrays - Avoid deep watchers on store state
- Use computed instead of methods for derived data
- Batch updates when possible
Testing
Testing
Copy
// tests/unit/store/inventory.test.ts
import { setActivePinia, createPinia } from 'pinia'
import { useInventoryStore } from '~/features/03_inventory'
describe('Inventory Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('should add item to cart', () => {
const store = useInventoryStore()
store.addItem({ name: 'Test', sku: 'TST001', price: 100 })
expect(store.items).toHaveLength(1)
})
})