Skip to main content

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

graph

Local TDComponent Q1{IsState data(React fetcheduseState)

from

Usage: 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/)

StoreKey stateNotes
useDashboardStoremetrics, monthlyPL, receivablesAging, recentTransactionsFetches from /api/v1/dashboard
useInvoiceStoreinvoices, pagination, filtersFetches from /api/v1/invoices
useExpenseStoreexpensesFetches from /api/v1/expenses
useContactStorecontactsFetches from /api/v1/contacts
useBankingStorebankAccounts, transactionsFetches from /api/v1/banking
useAuthStoreuser, organization, tokenAuth state — see auth section below

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 tree
  • Does not need to survive navigation
  • Does 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)

UseSidebar:

useReducerconst [expandedSections, setExpandedSections] = useState<string[]>([
  'Sales',
  'Purchases',
  'Reports',
])
when
state

Computed hasState more(React thanuseMemo)

2–3

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:

  • Must be shared across multiple route segments
  • Must survive in-app navigation (does not need to surviveavoid pageexpensive refresh)
  • recalculations.

  • Is

    Invoice fetched once and referenced in many places

List:

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

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)

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

  1. Phase 2a: Create stores with API integration
  2. Phase 2b: Replace useState with store hooks in components
  3. Phase 2c: Add optimistic updates and caching
  4. 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)

    UI

  1. StartPreferences: withlocalStorage useStateAuth inToken: the lowest possible component.
  2. Lift to parent only when a sibling needs the same state.
  3. Move to Zustand only when lifting would require passing through 3+ component levels (prop drilling).
  4. Move to URL state when the value should be bookmarkable, shareable, or survive refresh.
  5. Move to RSC + fetch when the value comes from the server and does not need client-side mutation.

6. What Not to Store in State

for
DataWhere it lives
JWT access tokenhttpOnly cookie (setsecure) byData server,Cache: read by Next.js middleware)
Locale preferenceAccept-Language header or locale route segment
Dark mode preferencelocalStorage + CSS prefers-color-scheme
Form field valuesReact Hook FormsessionStorage (notoptional, useState)
Temporary UI state (tooltip visible, dropdown open)Local useState in the component
Market/jurisdiction (RS/HR/BA)MarketContext (React Context — one provider at layout level)

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

OPENCurrent QUESTIONState:

OQ-6:
    MarketContext
  • React currently falls back to hardcoded RS defaults when organization is undefined. The fallback behavior in cross-market scenarioshooks (e.g.,useState, auseMemo, CroatianuseEffect)
  • org
  • Mock accessingdata fromimports
  • a
  • Local RScomponent IP)state
  • must
  • No bepersistence
  • explicitly
  • 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