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

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:

  • Must be shared across multiple route segments
  • Must survive in-app navigation (does not need to survive page refresh)
  • Is fetched once and referenced in many places
// 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

  1. Start with useState in 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

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: MarketContext currently falls back to hardcoded RS defaults when organization is undefined. The fallback behavior in cross-market scenarios (e.g., a Croatian org accessing from a RS IP) must be explicitly specified.