State Management
title: State Management
owner: vizu
last-updated: 2026-05-16
supersedes: docs/frontend/STATE-MANAGEMENT.md (2026-02-25 — duplicated FRONTEND-ARCHITECTURE.md sections)
status: canonical
Bilko State Management
SingleCurrent sourceState: ofPrimarily truthReact hooks (useState, useEffect)
Installed but Minimal Use: Zustand 4.5.0
Future State: Migrate to Zustand for global state decisions. The duplicated Mermaid diagram in the old FRONTEND-ARCHITECTURE.md and STATE-MANAGEMENT.md is retired — this file is canonical.
1.Current DecisionState TreePatterns
graphLocal TDComponent Q1{IsState data(React fetcheduseState)
fromUsage: theMost server?}components Q1use -->|Yes| Q2{Does it need client-side caching or polling?}
Q1 -->|No| Q3{Islocal state sharedfor acrossUI multipleinteractions components?}and Q2form -->|No|data.
A1[async RSC fetch — target pattern]
Q2 -->|Yes| A2[TanStack Query — not yet installed]
Q3 -->|No, local to one component| A3[useState / useReducer]
Q3 -->|Yes, cross-route| A4[Zustand store]
Q3 -->|URL-driven — filter, sort, page| A5[searchParams / useSearchParams]
A1 --> A6[Pass as props to Client Components]
2. Server State
Examples:
CurrentDashboard pattern:Page: Zustand stores populated via useEffect in every page component.
Target pattern: async RSC with fetch(), data passed as props to Client Components.
The useEffect + store pattern works but creates client-side waterfalls (visible as layout shift and skeleton flash on navigation). Migration is Wave B work.
Current Zustand Stores (in apps/web/lib/stores/)
| | |
| | |
| | |
| | |
| | |
|
TanStack Query (Future)
TanStack Query is the recommended solution for client-side server state once mock data is fully replaced. It provides: caching, deduplication, background refetch, optimistic updates. Install when the first page migrates to real API calls.
// TargetNo patternlocal afterstate TanStack— Querypurely ispresentational, installeduses importmock {data useQuery } from '@tanstack/react-query'
function InvoiceList() {
const { data, isLoading } = useQuery({
queryKey: ['invoices'],
queryFn: () => fetchInvoices(),
staleTime: 30_000,
})
}imports
Invoice 3.List Client State
useState / useReducer
Use for state that:Page:
Belongs to a single component or a small component treeDoes not need to survive navigationDoes not need to be read by sibling routes
//const Correct[statusFilter, —setStatusFilter] local= UIuseState<string>('all')
stateconst [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 [isOpen,customer, setIsOpen]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)
useReducerconst [expandedSections, setExpandedSections] = useState<string[]>([
'Sales',
'Purchases',
'Reports',
])
Computed hasState more(React thanuseMemo)
Purpose: relatedDerived values thatfrom update together (e.g., a multi-step wizard with 6 steps, each with its own substates).
Zustand
Use for props/state that:
// CorrectFiltered/sorted —invoice cross-component global statelist
const filteredInvoices = useMemo(() => {
user,let organizationfiltered = [...mockInvoices]
// Apply filters
if (statusFilter !== 'all') {
filtered = filtered.filter((inv) => inv.status === statusFilter)
}
if (searchQuery) {
const query = useAuthStore(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])
AuthInvoice store security note:Wizard: The useAuthStore stores user and organization objects. The JWT token must be stored in an httpOnly cookie (inaccessible to JavaScript), not in Zustand state. Any Zustand interface with token: string | null represents an XSS risk and must be removed. The lib/auth-provider.tsx pattern (cookie-based) is the target.
4. URL State
URL state is the correct choice for filter values, sort order, pagination, and any state that should survive a page refresh or be shareable via link.
// ReadingCustomer URLlist
stateconst customers = useMemo((RSC)) export=> defaultmockContacts.filter((c) function=> InvoicesPage({c.type searchParams,=== }:'customer'), {[])
searchParams:// {Calculate status?:totals
string;const page?:totals string= }useMemo(() })=> {
const statussubtotal = searchParams.statuslineItems.reduce((sum, ??item) 'all'=> sum + item.quantity * item.unitPrice, 0)
const pagevatTotal = Number(searchParams.pagelineItems.reduce(
??(sum, 1)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])
// WritingStats
URLconst statestats = useMemo((Client Component)
;('use client') import=> {
useRouter,const useSearchParamstotal = 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 }
from}, 'next/navigation'[filteredExpenses])
export
Banking InvoiceFilters(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 searchParams = useSearchParams()
const setStatushandleNext = () => {
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)
Package: zustand: ^4.5.0 (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 Stores
Auth Store
// Planned: stores/auth.ts
interface AuthState {
user: User | null
isAuthenticated: boolean
token: string | null
login: (email: string, password: string) => Promise<void>
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: () => 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 Store
// Planned: stores/ui.ts
interface UIState {
sidebarOpen: boolean
sidebarExpandedSections: string[]
theme: 'light' | 'dark'
toggleSidebar: () => void
toggleSection: (section: string) => void
setTheme: (theme: 'light' | 'dark') => void
}
Migration Strategy
- Phase 2a: Create stores with API integration
- Phase 2b: Replace useState with store hooks in components
- Phase 2c: Add optimistic updates and caching
- Phase 2d: Implement persistence (localStorage for UI prefs)
Example Migration:
Before (current):
// Invoice list page
const [invoices, setInvoices] = useState<Invoice[]>(mockInvoices)
After (Phase 2):
// Invoice list page
import { useInvoicesStore } from '@/stores/invoices'
const { invoices, fetchInvoices, isLoading } = useInvoicesStore()
useEffect(() => {
fetchInvoices({ status: statusFilter, dateRange })
}, [statusFilter, dateRange])
API Integration Pattern (Future)
API Client (lib/api.ts)
// Planned: lib/api.ts
const API_BASE = process.env.NEXT_PUBLIC_API_URL
export const api = {
get: async (endpoint: string) => {
const paramsres = await fetch(`${API_BASE}${endpoint}`, {
headers: { Authorization: `Bearer ${getToken()}` },
})
if (!res.ok) throw new URLSearchParams(searchParams.toString(Error(await res.text())
params.set('status'return res.json()
},
status)post: router.replace(async (endpoint: string, data: any) => {
const res = await fetch(`?${params.toString(API_BASE}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${getToken()}`,
},
body: JSON.stringify(data),
})
if (!res.ok) throw new Error(await res.text())
return res.json()
},
// PATCH, DELETE...
}
Store Integration
// Planned: stores/invoices.ts
export const useInvoicesStore = create<InvoicesState>((set, get) => ({
invoices: [],
isLoading: false,
error: null,
fetchInvoices: async (filters) => {
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}`)
}
Optimistic Updates (Future)
Concept: Update UI immediately, rollback on API failure
// Example: Delete invoice (future)
deleteInvoice: async (id) => {
// Optimistic update
const prevInvoices = get().invoices
set({ invoices: prevInvoices.filter((inv) => inv.id !== id) })
try {
await api.delete(`/invoices/${id}`)
} catch (error) {
// Rollback on failure
set({ invoices: prevInvoices, error: error.message })
toast.error('Failed to delete invoice')
}
}
Use router.replace (not push) for filter changes — they should not create new history entries.
5. State LiftingPersistence Rules(Future)
StartPreferences:Lift to parent only when a sibling needs the same state.Move to Zustand only when lifting would require passing through 3+ component levels (prop drilling).Move to URL state when the value should be bookmarkable, shareable, or survive refresh.Move to RSC + fetch when the value comes from the server and does not need client-side mutation.
UI withlocalStorage
Auth useStateinToken: the lowest possible component.
6. What Not to Store in State
httpOnly cookie ( | |
| |
| |
| |
|
7. MarketContext
Market-specific configuration (VAT rates, currency, fiscal adapter) is provided via lib/context/MarketContext.tsx. This is React Context (not Zustand) because it is set once at session start from the user's organization and does not change during a session.performance)
// ReadingExample: marketPersist configsidebar instate any(future)
Client Componentexport const marketuseUIStore = useMarket(create(
persist<UIState>(
(set) => ({
sidebarOpen: false,
toggleSidebar: () const=> defaultVatRateset((state) => market.vatRates[0]({ constsidebarOpen: currency!state.sidebarOpen =})),
market.currency}),
//{ name: 'EUR'bilko-ui-preferences' |},
'RSD'),
| 'BAM')
Summary
OPENCurrentQUESTIONState:OQ-6:
MarketContext- React
currently falls back to hardcoded RS defaults whenorganizationis undefined. The fallback behavior in cross-market scenarioshooks (e.g.,useState,auseMemo,CroatianuseEffect)org- Mock
accessingdatafromimportsa- Local
RScomponentIP)statemust- No
bepersistenceexplicitly- No
specified.global state- Zustand installed but unused
Future State (Phase 2):
- Zustand stores for global state
- API integration layer
- Loading/error states
- Optimistic updates
- State persistence (UI prefs)
- JWT token management