Skip to main content

State Management

BilkoDrop Frontend — State Management

CurrentCovers State:auth, Primarilyfeature Reactflags, hooksdata (useState,fetching useEffect)patterns, Installedand but Minimal Use: Zustand 4.5.0 Future State: Migrate to Zustand for globalclient-side state in src/drop-app/.


Current State Patterns

Local Component State (React useState)

Usage: Most components use local state for UI interactions and form data.

Examples:

Dashboard Page:

// No local stateAuthenticationpurelyuseAuth presentational, uses mock data imports

Invoice List Page:

const [statusFilter, setStatusFilter] = useState<string>("all")
const [searchQuery, setSearchQuery] = useState<string>("")
const [dateRange, setDateRange] = useState<string>("this-month")
const [sortColumn, setSortColumn] = useState<string>("date")
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc")

Invoice Wizard:

const [step, setStep] = useState(1)
const [customer, setCustomer] = useState<Contact | null>(null)
const [showAddCustomer, setShowAddCustomer] = useState(false)
const [invoiceDetails, setInvoiceDetails] = useState<InvoiceDetails>({...})
const [lineItems, setLineItems] = useState<LineItem[]>([...])
const [notes, setNotes] = useState("Thank you for your business!")
const [terms, setTerms] = useState("Payment due within 30 days.")
const [emailData, setEmailData] = useState({...})

Expenses Page:

const [isDialogOpen, setIsDialogOpen] = useState(false)
const [periodFilter, setPeriodFilter] = useState("This Month")
const [categoryFilter, setCategoryFilter] = useState("All Categories")
const [searchQuery, setSearchQuery] = useState("")
const [formData, setFormData] = useState({...})

Banking Page:

const [selectedAccount, setSelectedAccount] = useState(mockBankAccounts[0].id)

Reports Page:

const [plExpanded, setPlExpanded] = useState({
  revenue: true,
  expenses: true
})

VAT Report:

const [currentStep, setCurrentStep] = useState<'reconciliation' | 'audit' | 'summary'>('reconciliation')

Settings Page:

const [activeSection, setActiveSection] = useState('company')
const [companyData, setCompanyData] = useState({...})
const [vatSettings, setVatSettings] = useState({...})

Dashboard Layout:

const [sidebarOpen, setSidebarOpen] = useState(false)

Sidebar:

const [expandedSections, setExpandedSections] = useState<string[]>([
  "Sales",
  "Purchases",
  "Reports"
])

Computed State (React useMemo)

Purpose: Derived values from props/state to avoid expensive recalculations.

Invoice List:

// Filtered/sorted invoice list
const filteredInvoices = useMemo(() => {
  let filtered = [...mockInvoices]

  // Apply filters
  if (statusFilter !== "all") {
    filtered = filtered.filter((inv) => inv.status === statusFilter)
  }

  if (searchQuery) {
    const query = searchQuery.toLowerCase()
    filtered = filtered.filter(
      (inv) =>
        inv.customerName.toLowerCase().includes(query) ||
        inv.number.toLowerCase().includes(query)
    )
  }

  // Date range filter logic...

  // Sort logic...

  return filtered
}, [statusFilter, searchQuery, dateRange, sortColumn, sortDirection])

// Summary calculations
const summary = useMemo(() => {
  const total = filteredInvoices.length
  const byStatus = filteredInvoices.reduce((acc, inv) => {
    // Aggregate by status...
    return acc
  }, {})

  return { total, byStatus }
}, [filteredInvoices])

Invoice Wizard:

// Customer list
const customers = useMemo(
  () => mockContacts.filter((c) => c.type === "customer"),
  []
)

// Calculate totals
const totals = useMemo(() => {
  const subtotal = lineItems.reduce(
    (sum, item) => sum + item.quantity * item.unitPrice,
    0
  )
  const vatTotal = lineItems.reduce(
    (sum, item) =>
      sum + item.quantity * item.unitPrice * (item.vatRate / 100),
    0
  )
  const total = subtotal + vatTotal
  return { subtotal, vatTotal, total }
}, [lineItems])

Expenses Page:

// Filtered expenses
const filteredExpenses = useMemo(() => {
  return mockExpenses.filter(expense => {
    const matchesCategory = categoryFilter === "All Categories" || expense.category === categoryFilter
    const matchesSearch = searchQuery === "" ||
      expense.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
      expense.vendor.toLowerCase().includes(searchQuery.toLowerCase())
    return matchesCategory && matchesSearch
  })
}, [categoryFilter, searchQuery])

// Stats
const stats = useMemo(() => {
  const total = filteredExpenses.reduce((sum, exp) => {
    // Convert to EUR and sum...
  }, 0)
  const pending = filteredExpenses.filter(e => e.status === 'pending').length
  const approved = filteredExpenses.filter(e => e.status === 'approved').length
  const paid = filteredExpenses.filter(e => e.status === 'paid').length

  return { total, pending, approved, paid }
}, [filteredExpenses])

Banking Page:

// Total balance in EUR
const totalBalanceEUR = useMemo(() => {
  return mockBankAccounts.reduce((sum, acc) => {
    const eurAmount = acc.currency === 'EUR' ? acc.balance :
                      acc.currency === 'RSD' ? acc.balance / 117 :
                      acc.balance / 2 // BAM to EUR
    return sum + eurAmount
  }, 0)
}, [])

// Unreconciled transactions
const unreconciledTransactions = useMemo(() => {
  return mockBankTransactions.filter(tx => !tx.reconciled && tx.bankAccountId === selectedAccount)
}, [selectedAccount])

Navigation State (Next.js usePathname)

Sidebar:

const pathname = usePathname()

const isActive = (href: string | undefined) => {
  if (!href) return false
  return pathname === href
}

Usage: Highlights active navigation item based on current route.


Router State (Next.js useRouter)

Invoice Wizard:

const router = useRouter()

const handleNext = () => {
  if (step === 6) {
    alert("Invoice sent!")
    router.push("/invoices")
  } else {
    setStep(step + 1)
  }
}

const handleCancel = () => {
  if (confirm("Are you sure you want to cancel? All changes will be lost.")) {
    router.push("/invoices")
  }
}

Usage: Programmatic navigation after form submission or cancel.


Data Flow (Current)

Mock Data Import Pattern

All pages import mock data directly:

import { mockInvoices, mockExpenses, mockBankAccounts } from "@/lib/mock-data"

Issues:

  • No centralized state
  • Data changes lost on page refresh
  • No persistence
  • Each page re-imports same data

Data Transformation

Components transform mock data for display:

// Dashboard: Calculate metrics from raw data
const dashboardMetrics = {
  cashBalance: 2478170,
  revenueMTD: 485700,
  unpaidInvoices: 218200,
  // ...
}

// Invoice list: Filter/sort/search
const filteredInvoices = mockInvoices.filter(...)

// Banking: Currency conversion
const totalBalanceEUR = mockBankAccounts.reduce((sum, acc) => {
  const eurAmount = convertToEUR(acc.balance, acc.currency)
  return sum + eurAmount
}, 0)

Zustand (Installed but Not Used)Hook

Package:File: zustand: ^4.5.0src/lib/use-auth.ts (installed in package.json)

Current Usage: None

Planned Usage: Global state stores for:

  • User authentication state
  • Organization/company data
  • Cached invoices/expenses/contacts
  • UI preferences (theme, sidebar expanded)

Future State Architecture (Phase 2)

Planned Zustand StoresInterface

Auth Store

//function Planned:useAuth(redirectIfUnauthenticated?: stores/auth.ts
interface AuthStateboolean): {
  user: User | nullnull;
  isAuthenticated:loading: boolean
  token: string | null
  login: (email: string, password: string) => Promise<void>boolean;
  logout: () => void
  refreshToken: () => Promise<void>;
  }

Organization Store

// Planned: stores/organization.ts
interface OrgState {
  organization: Organization | null
  settings: OrgSettings
  updateSettings: (settings: Partial<OrgSettings>) => Promise<void>
}

Invoices Store

// Planned: stores/invoices.ts
interface InvoicesState {
  invoices: Invoice[]
  isLoading: boolean
  error: string | null
  fetchInvoices: (filters: InvoiceFilters) => Promise<void>
  createInvoice: (data: InvoiceCreateData) => Promise<Invoice>
  updateInvoice: (id: string, data: Partial<Invoice>) => Promise<void>
  deleteInvoice: (id: string) => Promise<void>
  sendInvoice: (id: string, emailData: EmailData) => Promise<void>
}

Expenses Store

// Planned: stores/expenses.ts
interface ExpensesState {
  expenses: Expense[]
  isLoading: boolean
  fetchExpenses: (filters: ExpenseFilters) => Promise<void>
  createExpense: (data: ExpenseCreateData) => Promise<Expense>
  updateExpense: (id: string, data: Partial<Expense>) => Promise<void>
  deleteExpense: (id: string) => Promise<void>
}

Banking Store

// Planned: stores/banking.ts
interface BankingState {
  accounts: BankAccount[]
  transactions: BankTransaction[]
  isLoading: boolean
  fetchAccounts:refreshUser: () => Promise<void>
  fetchTransactions: (accountId: string) => Promise<void>
  importTransactions: (accountId: string, file: File) => Promise<void>
  reconcileTransaction: (txId: string) => Promise<void>
  linkTransaction: (txId: string, linkType: string, linkId: string) => Promise<void>;
}

UI

Default: Store

redirectIfUnauthenticated = true

User Model

//interface Planned:User stores/ui.ts{
  id: string;
  email: string;
  firstName: string;
  lastName: string;
  totalBalance: number;
  bankAccounts: BankAccount[];
  kycStatus: string;
}

interface UIStateBankAccount {
  sidebarOpen:id: booleanstring;
  sidebarExpandedSections:bankName: string[]string;
  theme:accountNumber: 'light'string;
  |balance: 'dark'number;
  toggleSidebar:currency: ()string;
  =>isPrimary: void
  toggleSection: (section: string) => void
  setTheme: (theme: 'light' | 'dark') => voidboolean;
}

Migration StrategyBehavior

  1. PhaseOn 2a:mount: Createfetches storesGET /api/auth/me with APIcredentials: integration"include"
  2. PhaseIf 2b:401 Replaceand useStateredirectIfUnauthenticated withis storetrue: hooksredirects into components/login
  3. Phaselogout(): 2c:calls AddPOST optimistic/api/auth/logout, updatesredirects andto caching/login
  4. PhaserefreshUser(): 2d:re-fetches Implement/api/auth/me persistenceto (localStorageupdate foruser UI prefs)state

Example

Usage Migration:

Before (current):

Pattern

// InvoiceStandard listprotected page
const [invoices,{ setInvoices]user, loading } = useStateuseAuth();
if (loading) return <Invoice[]Skeleton />;
// user is guaranteed non-null after loading

// Page that checks auth without redirect
const { user } = useAuth(false);

Auth Flow

Login page → POST /api/auth/login → cookie set → router.push("/dashboard")
Dashboard → useAuth() → GET /api/auth/me → User object
Logout → POST /api/auth/logout → cookie cleared → redirect /login

Feature Flags

File: src/lib/feature-flags.ts

Available Flags

Flag NameDefaultUsed In
virtualCardsfalsecards page (mockInvoices)gate)
physicalCardsfalsecards page (order physical)
cardDetailsfalsecards page (show details)
cardFreezefalsecards page (freeze/unfreeze)
cardPinfalsecards page (change PIN)
spendingLimitsfalsecards page (spending limits)
notificationstruenotification features
merchantDashboardtruemerchant page (gate)

Environment Variable Pattern

NEXT_PUBLIC_FF_VIRTUAL_CARDS=true
NEXT_PUBLIC_FF_PHYSICAL_CARDS=false

AfterConvention: (PhaseNEXT_PUBLIC_FF_ 2):+ SCREAMING_SNAKE_CASE version of flag name.

API

// InvoiceServer-side
listisEnabled(flagName: pagestring): importboolean
{getAllFlags(): useInvoicesStoreRecord<string, }boolean>
fromfeatureGate(flagName: '@/stores/invoices'string): middleware  // Returns 404 if flag disabled

// Client-side (React hooks)
useFeatureFlag(flagName: string): boolean
useFeatureFlags(): Record<string, boolean>

Usage Pattern

// Page-level gate (redirects if feature disabled)
const { invoices, fetchInvoices, isLoading }cardsEnabled = useInvoicesStore(useFeatureFlag("virtualCards");
if (!cardsEnabled) return <div>Feature not available</div>;

// Conditional rendering
const physicalEnabled = useFeatureFlag("physicalCards");
{physicalEnabled && <OrderPhysicalCard />}

Data Fetching Patterns

Pattern 1: Page-Level Fetch on Mount

Most pages fetch data in a useEffect on mount. No SWR, React Query, or other caching library is used.

const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);

useEffect(() => {
  fetchInvoices(fetch("/api/endpoint", { status:credentials: statusFilter, dateRange"include" })
    .then(res => res.json())
    .then(json => setData(json.data))
    .catch(() => {})
    .finally(() => setLoading(false));
}, [statusFilter, dateRange]]);

Pages

APIusing Integrationthis pattern:

  • dashboard/page.tsx — fetches /api/transactions?limit=10
  • history/page.tsx — fetches /api/transactions?type={filter}&limit=50
  • send/page.tsx — fetches /api/recipients and /api/rates
  • merchant/page.tsx — fetches /api/merchants/dashboard, /api/merchants/transactions, /api/merchants/qr
  • cards/page.tsx — fetches /api/cards (FUTURE — feature-flagged)

Pattern (Future)

2: User Data from Auth Hook

Some pages rely entirely on the useAuth() hook for their data, with no additional fetches.

Pages using this pattern:

  • accounts/page.tsx — reads user.bankAccounts
  • profile/page.tsx — reads user.firstName, user.lastName, user.email

APIPattern Client3: (lib/api.ts)Form Submission

Form pages use async handlers that POST data and handle success/error states.

// Planned: lib/api.ts
const API_BASEhandleSubmit = process.env.NEXT_PUBLIC_API_URL

export const api = {
  get: async (endpoint: string)) => {
  setLoading(true);
  const res = await fetch(`${API_BASE}${endpoint}`, {
      headers: { Authorization: `Bearer ${getToken()}` }
    })
    if (!res.ok) throw new Error(await res.text())
    return res.json()
  },

  post: async (endpoint: string, data: any) => {
    const res = await fetch(`${API_BASE}${endpoint}`"/api/endpoint", {
    method: 'POST'"POST",
    headers: { '"Content-Type'Type": '"application/json',
        Authorization: `Bearer ${getToken()}`json" },
    body: JSON.stringify(data)formData),
  });
  if (!res.ok) throw new Error(await res.text())
    return res.json()
  },{ /* success state */ PATCH, DELETE... }
  

Store Integration

// Planned: stores/invoices.ts
export const useInvoicesStore = create<InvoicesState>((set, get) => ({
  invoices: [],
  isLoading: false,
  error: null,

  fetchInvoices: async (filters) =>else { set({ isLoading: true, error: null })
    try {
      const data = await api.get(`/invoices?${buildQueryString(filters)}`)
      set({ invoices: data, isLoading: false })
    } catch (error) {
      set({ error: error.message, isLoading: false })
    }
  },

  createInvoice: async (data) => {
    const invoice = await api.post('/invoices', data)
    set({ invoices: [...get().invoices, invoice] })
    return invoice
  },

  // Other CRUD methods...
}))

Loading States (Future)

Current: No loading states (instant mock data)

Future: Loading skeletons and states

// Component usage (future)
const { invoices, isLoading } = useInvoicesStore()

if (isLoading) {
  return <InvoiceListSkeleton />
}

Error Handling (Future)

Current: No error handling (mock data never fails)

Future: Error boundaries and toast notifications

// Store* error state (future)
const { error*/ }
  = useInvoicesStore()

if (error) {
  toast.error(`Failed to load invoices: ${error}`)setLoading(false);
};

Pages

Optimisticusing Updatesthis pattern:

  • login/page.tsx — POST /api/auth/login
  • onboarding/page.tsx — POST /api/auth/register
  • send/page.tsx — POST /api/transactions/remittance
  • scan/page.tsx — POST /api/transactions/qr-payment
  • cards/page.tsx — POST/PATCH/DELETE /api/cards/* (Future)

FUTURE — feature-flagged)

Pattern 4: Filter-Driven Refetch

Concept:History Updatepage UIrefetches immediately,when rollbackfilter onchanges APIvia failureuseEffect dependency.

//const Example:[filter, DeletesetFilter] invoice= useState("all");

useEffect((future)
deleteInvoice: async (id)) => {
  // Optimisticrefetch updatewith constnew prevInvoicesfilter
  = get().invoices
  set({ invoices: prevInvoices.filter(inv => inv.id !== id) })

  try {
    await api.delete(fetch(`/invoices/api/transactions?type=${id}`filter}&limit=50`, ...)
}, catch (error) {
    // Rollback on failure
    set({ invoices: prevInvoices, error: error.message }[filter])
    toast.error('Failed to delete invoice')
  }
};

Client State PersistenceSummary

No global state management library (Future)Redux, Zustand, Jotai, etc.) is used. All state is local component state via useState.

State TypeMechanismScope
Auth/UseruseAuth() custom hookPer-component (re-fetches on each mount)
Feature flagsuseFeatureFlag() hookPer-component (reads env vars)
Page datauseState + useEffect fetchComponent-local
Form stateuseState per fieldComponent-local
UI state (modals, tabs)useStateComponent-local
NavigationNext.js useRouter / usePathnameFramework-provided

API Routes (from source code)

All API routes are under src/app/api/. The frontend calls these endpoints:

EndpointMethodCalled From
/api/auth/loginPOSTlogin page
/api/auth/logoutPOSTuseAuth hook, profile page
/api/auth/meGETuseAuth hook
/api/auth/registerPOSTonboarding page
/api/transactionsGETdashboard, history pages
/api/transactions/remittancePOSTsend page
/api/transactions/qr-paymentPOSTscan page
/api/recipientsGETsend page
/api/ratesGETsend page
/api/cardsGET/POSTcards page (FUTURE)
/api/cards/{id}PATCHcards page (freeze/unfreeze) (FUTURE)
/api/cards/{id}DELETEcards page (FUTURE)
/api/merchants/dashboardGETmerchant page
/api/merchants/transactionsGETmerchant page
/api/merchants/qrGETmerchant page

Middleware

UI Preferences:File: localStorage Auth Token: httpOnly cookiesrc/middleware.ts (secure)Next.js Data Cache: sessionStorage (optional, for performance)middleware)

Additional middleware modules in // Example: Persist sidebar state (future) export const useUIStore = create( persist<UIState>( (set) => ({ sidebarOpen: false, toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })) }), { name: 'bilko-ui-preferences' } ) ) src/lib/middleware/


Summary

Current State::

  • Reactauth-middleware.ts hooks (useState,JWT/session useMemo, useEffect)validation
  • Mockerror-handler.ts data importsCentralized error handling
  • Localvalidation.ts component state
  • Request
  • No persistence
  • No global state
  • Zustand installed but unusedvalidation
  • Zustand stores for global state
  • API integration layer
  • Loading/error states
  • Optimistic updates
  • State persistence (UI prefs)
  • JWT token management