Skip to main content

State Management (Pinia)

MStore Dashboard menggunakan Pinia 3.x sebagai state management solution dengan pendekatan composition API.

Overview

State Architecture

Three Levels of State

LevelWhereWhen to Use
Global StatePinia StoreShared across multiple pages
Page StateComposableSpecific to one page
Component Stateref/reactiveLocal to one component

Decision Matrix

SituationSolutionExample
User authenticationPinia StoreuseAuthStore
Shopping cartPinia StoreuseCartStore
Form dataComposableuseInventoryForm
Modal open/closeComponent refconst isOpen = ref(false)
Search/filterComposableuseInventoryList
PaginationComposableusePagination

Setup

Installation

Pinia sudah terinstall via @pinia/nuxt module:
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@pinia/nuxt'],
})

Persistence Plugin

Untuk persistent state, gunakan pinia-plugin-persistedstate:
// plugins/pinia.ts
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

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

Store Patterns

Basic Store (Composition API)

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

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

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

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

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

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

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

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

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

export const useCartStore = defineStore('cart', () => {
  // ...
}, {
  persist: {
    storage: sessionStorage,  // Use sessionStorage instead
  },
})

Best Practices

  • One store per domain
  • Keep stores focused and small
  • Use composables for page-specific logic
  • Use computed for derived state
  • Never persist sensitive data (tokens, passwords)
  • Use memory-only for access tokens
  • Validate data before storing
  • Clear sensitive data on logout
  • Use shallowRef for large arrays
  • Avoid deep watchers on store state
  • Use computed instead of methods for derived data
  • Batch updates when possible
// 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)
  })
})

Next Steps