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
Single source of truth for state decisions. The duplicated Mermaid diagram in the old FRONTEND-ARCHITECTURE.md and STATE-MANAGEMENT.md is retired — this file is canonical.
1. Decision Tree
graph TD
Q1{Is data fetched from the server?}
Q1 -->|Yes| Q2{Does it need client-side caching or polling?}
Q1 -->|No| Q3{Is state shared across multiple components?}
Q2 -->|No| 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
Current pattern: 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/)
| Store | Key state | Notes |
|---|---|---|
useDashboardStore |
metrics, monthlyPL, receivablesAging, recentTransactions | Fetches from /api/v1/dashboard |
useInvoiceStore |
invoices, pagination, filters | Fetches from /api/v1/invoices |
useExpenseStore |
expenses | Fetches from /api/v1/expenses |
useContactStore |
contacts | Fetches from /api/v1/contacts |
useBankingStore |
bankAccounts, transactions | Fetches from /api/v1/banking |
useAuthStore |
user, organization, token | Auth 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.
// Target pattern after TanStack Query is installed
import { useQuery } from '@tanstack/react-query'
function InvoiceList() {
const { data, isLoading } = useQuery({
queryKey: ['invoices'],
queryFn: () => fetchInvoices(),
staleTime: 30_000,
})
}
3. Client State
useState / useReducer
Use for state that:
- 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
// Correct — local UI state
const [step, setStep] = useState(1)
const [isOpen, setIsOpen] = useState(false)
Use useReducer when state has more than 2–3 related values that update together (e.g., a multi-step wizard with 6 steps, each with its own substates).
Zustand
Use for state that:
// Correct — cross-component global state
const { user, organization } = useAuthStore()
Auth store security note: 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.
// Reading URL state (RSC)
export default function InvoicesPage({
searchParams,
}: {
searchParams: { status?: string; page?: string }
}) {
const status = searchParams.status ?? 'all'
const page = Number(searchParams.page ?? 1)
}
// Writing URL state (Client Component)
;('use client')
import { useRouter, useSearchParams } from 'next/navigation'
export function InvoiceFilters() {
const router = useRouter()
const searchParams = useSearchParams()
const setStatus = (status: string) => {
const params = new URLSearchParams(searchParams.toString())
params.set('status', status)
router.replace(`?${params.toString()}`)
}
}
Use router.replace (not push) for filter changes — they should not create new history entries.
5. State Lifting Rules
- Start with
useStatein the lowest possible component. - 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.
6. What Not to Store in State
| Data | Where it lives |
|---|---|
| JWT access token | httpOnly cookie (set by server, read by Next.js middleware) |
| Locale preference | Accept-Language header or locale route segment |
| Dark mode preference | localStorage + CSS prefers-color-scheme |
| Form field values | React Hook Form (not 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.
// Reading market config in any Client Component
const market = useMarket()
const defaultVatRate = market.vatRates[0]
const currency = market.currency // 'EUR' | 'RSD' | 'BAM'
OPEN QUESTION OQ-6:
MarketContextcurrently falls back to hardcoded RS defaults whenorganizationis undefined. The fallback behavior in cross-market scenarios (e.g., a Croatian org accessing from a RS IP) must be explicitly specified.