State Management
BilkoDrop Frontend — State Management
CurrentCoversState:auth,PrimarilyfeatureReactflags,hooksdata(useState,fetchinguseEffect)patterns,Installedandbut Minimal Use:Zustand 4.5.0Future State:Migrate to Zustand for globalclient-side state insrc/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 stateAuthentication — purelyuseAuth 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)
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)
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 stateData changes lost on page refreshNo persistenceEach 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 stateOrganization/company dataCached invoices/expenses/contactsUI 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: StoreredirectIfUnauthenticated = 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
PhaseOn2a:mount:CreatefetchesstoresGET /api/auth/mewithAPIcredentials:integration"include"PhaseIf2b:401ReplaceanduseStateredirectIfUnauthenticatedwithisstoretrue:hooksredirectsintocomponents/loginPhaselogout():2c:callsAddPOST,optimistic/api/auth/logoutupdatesredirectsandtocaching/loginPhaserefreshUser():2d:re-fetchesImplement/api/auth/mepersistenceto(localStorageupdateforuserUI prefs)state
ExampleUsage
Migration:
Before (current):
// 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 Name | Default | Used In |
|---|---|---|
virtualCards |
false |
cards page ( |
physicalCards |
false |
cards page (order physical) |
cardDetails |
false |
cards page (show details) |
cardFreeze |
false |
cards page (freeze/unfreeze) |
cardPin |
false |
cards page (change PIN) |
spendingLimits |
false |
cards page (spending limits) |
notifications |
true |
notification features |
merchantDashboard |
true |
merchant 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=10history/page.tsx— fetches/api/transactions?type={filter}&limit=50send/page.tsx— fetches/api/recipientsand/api/ratesmerchant/page.tsx— fetches/api/merchants/dashboard,/api/merchants/transactions,/api/merchants/qrcards/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— readsuser.bankAccountsprofile/page.tsx— readsuser.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/loginonboarding/page.tsx— POST/api/auth/registersend/page.tsx— POST/api/transactions/remittancescan/page.tsx— POST/api/transactions/qr-paymentcards/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 Type | Mechanism | Scope |
|---|---|---|
| Auth/User | useAuth() custom hook |
Per-component (re-fetches on each mount) |
| Feature flags | useFeatureFlag() hook |
Per-component (reads env vars) |
| Page data | useState + useEffect fetch |
Component-local |
| Form state | useState per field |
Component-local |
| UI state (modals, tabs) | useState |
Component-local |
| Navigation | Next.js useRouter / usePathname |
Framework-provided |
API Routes (from source code)
All API routes are under src/app/api/. The frontend calls these endpoints:
| Endpoint | Method | Called From |
|---|---|---|
/api/auth/login |
POST | login page |
/api/auth/logout |
POST | useAuth hook, profile page |
/api/auth/me |
GET | useAuth hook |
/api/auth/register |
POST | onboarding page |
/api/transactions |
GET | dashboard, history pages |
/api/transactions/remittance |
POST | send page |
/api/transactions/qr-payment |
POST | scan page |
/api/recipients |
GET | send page |
/api/rates |
GET | send page |
/api/cards |
GET/POST | cards page (FUTURE) |
/api/cards/{id} |
PATCH | cards page (freeze/unfreeze) (FUTURE) |
/api/cards/{id} |
DELETE | cards page (FUTURE) |
/api/merchants/dashboard |
GET | merchant page |
/api/merchants/transactions |
GET | merchant page |
/api/merchants/qr |
GET | merchant 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.tshooks—(useState,JWT/sessionuseMemo, useEffect)validationMockerror-handler.tsdata—importsCentralized error handlingLocalvalidation.tscomponent—stateRequest No persistenceNo global stateZustand installed but unusedvalidation
Zustand stores for global stateAPI integration layerLoading/error statesOptimistic updatesState persistence (UI prefs)JWT token management