Skip to main content

Data Flow Patterns

Dokumentasi lengkap tentang alur data dari UI layer hingga backend API di MStore Dashboard.

Overview

Layer Responsibilities

1. Page Component

Responsibility: Render UI dan handle user interactions
<!-- app/pages/inventory/index.vue -->
<script setup lang="ts">
import { useInventoryList, InventoryTable } from '~/features/03_inventory'

// Use composable for page logic
const {
  items,
  loading,
  error,
  searchQuery,
  fetchItems,
} = useInventoryList()

// Lifecycle
onMounted(() => {
  fetchItems()
})

// Event handlers
const handleSearch = (query: string) => {
  searchQuery.value = query
}
</script>

<template>
  <div class="p-6">
    <UInput
      v-model="searchQuery"
      placeholder="Search inventory..."
      @input="handleSearch"
    />

    <UAlert v-if="error" color="red" :title="error" />

    <InventoryTable
      :items="items"
      :loading="loading"
    />
  </div>
</template>

2. Composable

Responsibility: Page-specific logic, koordinasi antara UI dan store
// features/03_inventory/composables/useInventoryList.ts
export const useInventoryList = () => {
  const store = useInventoryStore()

  // Local page state
  const searchQuery = ref('')
  const sortBy = ref<'name' | 'price' | 'stock'>('name')
  const sortOrder = ref<'asc' | 'desc'>('asc')

  // Computed from store (reactive)
  const items = computed(() => store.items)
  const loading = computed(() => store.loading)
  const error = computed(() => store.error)

  // Local computed (derived from local + store state)
  const sortedItems = computed(() => {
    const sorted = [...items.value]
    sorted.sort((a, b) => {
      const aVal = a[sortBy.value]
      const bVal = b[sortBy.value]

      if (sortOrder.value === 'asc') {
        return aVal > bVal ? 1 : -1
      }
      return aVal < bVal ? 1 : -1
    })
    return sorted
  })

  // Actions
  const fetchItems = async () => {
    await store.fetchItems({
      search: searchQuery.value || undefined,
    })
  }

  // Watch for search changes with debounce
  const debouncedFetch = useDebounceFn(fetchItems, 300)

  watch(searchQuery, () => {
    debouncedFetch()
  })

  return {
    // Expose state
    searchQuery,
    sortBy,
    sortOrder,
    // Expose computed
    items: sortedItems,
    loading,
    error,
    // Expose actions
    fetchItems,
  }
}

3. Pinia Store

Responsibility: Global state management, API coordination
// features/03_inventory/store/inventory.store.ts
import { defineStore } from 'pinia'
import { getInventoryList, createInventory, updateInventory, deleteInventory } from '../api'
import type { Inventory, InventoryQueryParams, InventoryResponse } from '../types'

export const useInventoryStore = defineStore('inventory', () => {
  // State
  const items = ref<Inventory[]>([])
  const loading = ref(false)
  const error = ref<string | null>(null)
  const meta = ref({
    total: 0,
    page: 1,
    limit: 20,
  })

  // Getters
  const getById = computed(() => (id: string) =>
    items.value.find(item => item.id === id)
  )

  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
      meta.value = response.meta
    } catch (e) {
      error.value = e instanceof Error ? e.message : 'Failed to fetch inventory'
      throw e
    } finally {
      loading.value = false
    }
  }

  const addItem = async (data: Omit<Inventory, 'id' | 'createdAt'>) => {
    loading.value = true

    try {
      const newItem = await createInventory(data)
      items.value.push(newItem)
      return newItem
    } catch (e) {
      error.value = e instanceof Error ? e.message : 'Failed to create item'
      throw e
    } finally {
      loading.value = false
    }
  }

  const updateItem = async (id: string, data: Partial<Inventory>) => {
    const index = items.value.findIndex(item => item.id === id)
    if (index === -1) throw new Error('Item not found')

    try {
      const updated = await updateInventory(id, data)
      items.value[index] = updated
      return updated
    } catch (e) {
      error.value = e instanceof Error ? e.message : 'Failed to update item'
      throw e
    }
  }

  const removeItem = async (id: string) => {
    try {
      await deleteInventory(id)
      items.value = items.value.filter(item => item.id !== id)
    } catch (e) {
      error.value = e instanceof Error ? e.message : 'Failed to delete item'
      throw e
    }
  }

  const $reset = () => {
    items.value = []
    loading.value = false
    error.value = null
    meta.value = { total: 0, page: 1, limit: 20 }
  }

  return {
    // State
    items,
    loading,
    error,
    meta,
    // Getters
    getById,
    lowStockItems,
    // Actions
    fetchItems,
    addItem,
    updateItem,
    removeItem,
    $reset,
  }
})

4. API Layer

Responsibility: HTTP communication dengan backend
// features/03_inventory/api/getList.ts
import type { Inventory, InventoryQueryParams, InventoryResponse } from '../types'

export const getInventoryList = async (
  params?: InventoryQueryParams
): Promise<InventoryResponse> => {
  const query = new URLSearchParams()

  if (params?.page) query.set('page', params.page.toString())
  if (params?.limit) query.set('limit', params.limit.toString())
  if (params?.search) query.set('search', params.search)
  if (params?.category) query.set('category', params.category)
  if (params?.sortBy) query.set('sortBy', params.sortBy)
  if (params?.sortOrder) query.set('sortOrder', params.sortOrder)

  return await $fetch<InventoryResponse>('/api/v1/inventory', {
    method: 'GET',
    query: Object.fromEntries(query),
  })
}
// features/03_inventory/api/create.ts
import type { Inventory, CreateInventoryDto } from '../types'

export const createInventory = async (
  data: CreateInventoryDto
): Promise<Inventory> => {
  return await $fetch<Inventory>('/api/v1/inventory', {
    method: 'POST',
    body: data,
  })
}
// features/03_inventory/api/update.ts
import type { Inventory, UpdateInventoryDto } from '../types'

export const updateInventory = async (
  id: string,
  data: UpdateInventoryDto
): Promise<Inventory> => {
  return await $fetch<Inventory>(`/api/v1/inventory/${id}`, {
    method: 'PUT',
    body: data,
  })
}
// features/03_inventory/api/delete.ts
export const deleteInventory = async (id: string): Promise<void> => {
  await $fetch(`/api/v1/inventory/${id}`, {
    method: 'DELETE',
  })
}

Complete Data Flow Example

Creating New Item

Error Handling Flow

// Composable level - user-friendly error handling
const useInventoryList = () => {
  const store = useInventoryStore()
  const toast = useToast()

  const fetchItems = async () => {
    try {
      await store.fetchItems()
    } catch (error) {
      // Show user-friendly message
      toast.add({
        title: 'Error',
        description: 'Failed to load inventory. Please try again.',
        color: 'red',
      })
    }
  }

  return { fetchItems, error: computed(() => store.error) }
}
// Store level - state management
const fetchItems = async (params?: InventoryQueryParams) => {
  loading.value = true
  error.value = null

  try {
    const response = await getInventoryList(params)
    items.value = response.data
  } catch (e) {
    // Store error for UI display
    error.value = e instanceof Error ? e.message : 'Unknown error'
    // Re-throw for composable to handle
    throw e
  } finally {
    loading.value = false
  }
}
// API level - HTTP error handling
export const getInventoryList = async (params?: InventoryQueryParams) => {
  try {
    return await $fetch<InventoryResponse>('/api/v1/inventory', {
      method: 'GET',
      query: params,
    })
  } catch (error) {
    // Transform API errors
    if (error instanceof FetchError) {
      if (error.status === 401) {
        throw new Error('Please login to continue')
      }
      if (error.status === 403) {
        throw new Error('You do not have permission to view inventory')
      }
      if (error.status === 500) {
        throw new Error('Server error. Please try again later.')
      }
    }
    throw error
  }
}

Optimistic Updates

Untuk UX yang lebih baik, update UI sebelum server response:
// Store with optimistic update
const removeItem = async (id: string) => {
  // Save current state for rollback
  const previousItems = [...items.value]

  // Optimistic update
  items.value = items.value.filter(item => item.id !== id)

  try {
    await deleteInventory(id)
  } catch (e) {
    // Rollback on error
    items.value = previousItems
    error.value = 'Failed to delete item'
    throw e
  }
}

Best Practices

  • Store adalah single source of truth untuk domain state
  • Composables hanya membaca dari store
  • Components tidak langsung memodifikasi store state
  • API layer: Transform HTTP errors ke user-friendly messages
  • Store: Simpan error state untuk UI
  • Composable: Handle errors dengan toast/notifications
  • Component: Display error UI
  • Gunakan loading state di store
  • Show skeleton/spinner saat loading
  • Disable form buttons saat submitting
  • Prevent double-submission
  • Validate di form level (composable)
  • Validate lagi di API level (backend)
  • Show validation errors per field
  • Clear errors on input change

Next Steps