Skip to main content

State Management


title: State Management Architectureowner: 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

Project:Current pattern: {{PROJECT_NAME}}Zustand stores populated via useEffect in every page component. Version:Target pattern: {{VERSION}}async Date:RSC {{DATE}}with Author:fetch(), {{AUTHOR}}data Status:passed Draftas |props Into ReviewClient | Approved Reviewers: {{REVIEWERS}}Components.

The

DocumentuseEffect History

+ 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/)

VersionStore DateKey state AuthorChanges
0.1{{DATE}}{{AUTHOR}}Initial draft

1. State Architecture Overview

{{PROJECT_NAME}} uses a layered state management approach:

LayerLibraryScope
Server state{{TanStack Query / SWR / Apollo}}API data, caching, synchronization
Client / UI state{{Zustand / Redux Toolkit / Pinia}}Application-wide UI state
URL stateNative router APIsFilters, pagination, search
Form state{{React Hook Form / Formik / VeeValidate}}Form data, validation
Persistent state{{localStorage / cookies}}User preferences, tokens

Guiding principle: Server state is NOT stored in client state. API data lives in the query cache — client state holds only UI concerns (sidebar open, selected theme, modal visibility).


2. Data Flow Diagram

flowchart TD
    User["User Interaction"] --> Component["Component"]

    Component -->|"API call"| QueryCache["Query Cache\n(TanStack Query)"]
    Component -->|"UI action"| ClientStore["Client Store\n(Zustand)"]
    Component -->|"Navigate"| URLState["URL State\n(Router)"]
    Component -->|"Form input"| FormState["Form State\n(RHF)"]

    QueryCache -->|"fetch / mutation"| API["Backend API"]
    QueryCache -->|"cached data"| Component
    ClientStore -->|"state slice"| Component
    URLState -->|"params"| Component
    FormState -->|"values / errors"| Component

    ClientStore -->|"user prefs"| LocalStorage["localStorage\n(Persistent)"]
    LocalStorage -->|"hydrate on load"| ClientStore

3. State Categories

3.1 Server State (API Data)

Library: {{TanStack Query v5}}

Configuration:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,        // 5 minutes
      gcTime: 1000 * 60 * 30,          // 30 minutes garbage collection
      retry: 2,
      refetchOnWindowFocus: true,
      refetchOnReconnect: true,
    },
    mutations: {
      retry: 0,
    },
  },
});

Query key convention:

// Hierarchical keys for precise invalidation
['users']                          // all user queries
['users', { page: 1, search: '' }] // paginated user list
['users', userId]                  // single user
['users', userId, 'posts']         // user's posts

Stale time per resource type:

ResourceStale TimeGC TimeRationale
User profile5 min30 minChanges infrequently
Dashboard stats30 sec5 minNear-realtime
Config / enums1 hour24 hoursRarely changes
Notifications0 (always fresh)2 minTime-sensitive

3.2 Client State (UI State)

Library: {{Zustand}}

Store slices:

state}}
SliceFileResponsibilityNotes
uiStoreuseDashboardStore src/stores/ui.store.tsmetrics, monthlyPL, receivablesAging, recentTransactions SidebarFetches open,from active modal, theme/api/v1/dashboard
authStoreuseInvoiceStore src/stores/auth.store.tsinvoices, pagination, filters CurrentFetches user,from roles, session token/api/v1/invoices
notificationStoreuseExpenseStore src/stores/notification.store.tsexpenses ToastFetches queue,from unread count/api/v1/expenses
{{featureStore}}useContactStore contactsFetches from src/stores/{{feature}}.store.ts/api/v1/contacts
useBankingStore {{Feature-specificbankAccounts, UItransactions Fetches from /api/v1/banking
useAuthStoreuser, organization, tokenAuth state — see auth section below

TanStack Query (Future)

SliceTanStack template: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.

// src/stores/ui.store.tsTarget pattern after TanStack Query is installed
import { createuseQuery } from 'zustand';@tanstack/react-query'

interfacefunction UIStateInvoiceList() {
  sidebarOpen:const boolean;{ activeModal:data, string | null;
  theme: 'light' | 'dark' | 'system';isLoading } interface= UIActions useQuery({
    setSidebarOpen:queryKey: (open:['invoices'],
    boolean) => void;
  openModal: (id: string) => void;
  closeModal:queryFn: () => void;
  setTheme: (theme: UIState['theme']fetchInvoices() => void;
}

export const useUIStore = create<UIState & UIActions>((set) => ({
  sidebarOpen: true,
  activeModal: null,
  theme: 'system',
    setSidebarOpen:staleTime: (open) => set({ sidebarOpen: open30_000,
  }),
  openModal: (id) => set({ activeModal: id
}),
  closeModal: () => set({ activeModal: null }),
  setTheme: (theme) => set({ theme }),
}));

3. Client State

3.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 sourcecorrect ofchoice truth for:

  • Search /for filter queries
  • values,
  • Paginationsort (page,order, pageSize)
  • Sort columnpagination, and direction
  • any
  • Activestate tabthat /should viewsurvive mode
  • a
  • Modalpage IDrefresh (whenor deep-linkable)
  • be
shareable

Convention:

via
/users?page=2&pageSize=25&search=john&sort=name&dir=asc&status=active

Library: Native URLSearchParams + router useSearchParams hook

Serialization helper: src/lib/url-state.tslink.

// Type-safeReading URL paramstate parsing(RSC)
withexport fallbacksdefault 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 parseListParams(params: URLSearchParams): ListParamsInvoiceFilters() {
  returnconst router = useRouter()
  const searchParams = useSearchParams()

  const setStatus = (status: string) => {
    page:const Number(params.get('page'params = new URLSearchParams(searchParams.toString())
    ?? 1)params.set('status', pageSize:status)
    Number(router.replace(`?${params.get('pageSize'toString()}`)
  ?? 25),
    search: params.get('search') ?? '',
    sort: params.get('sort') ?? 'createdAt',
    dir: (params.get('dir') as 'asc' | 'desc') ?? 'desc',
  };
}

Use router.replace (not push) for filter changes — they should not create new history entries.


3.4 Form

5. State

Lifting

Library:Rules

  1. Start with {{React Hook Form v7}}useState Validation:in {{Zod}}

    the

    Pattern:

    lowest
    constpossible schemacomponent.
  2. =
  3. Lift z.object({to email:parent z.string()only when a sibling needs the same state.
  4. Move to Zustand only when lifting would require passing through 3+ component levels (prop drilling).email('Invalid
  5. email'),
  6. Move name:to z.string().min(2,URL 'Namestate mustwhen the value should be atbookmarkable, leastshareable, 2or characters'),survive }); const form = useForm<z.infer<typeof schema>>({ resolver: zodResolver(schema), defaultValues: { email: '', name: '' }, });

    Rules:

    • Form state NEVER leaks into global storerefresh.
    • SchemaMove validationto livesRSC + fetch when the value comes from the server and does not need client-side mutation.

6. What Not to Store in src/schemas/ — reused for API validation
  • Complex multi-step forms use form context + Stepper component

  • 3.5 Persistent State

    Whereit
    Data Storage Library Encryptionlives
    ThemeJWT access tokenhttpOnly cookie (set by server, read by Next.js middleware)
    Locale preferenceAccept-Language header or locale route segment
    Dark mode preference localStorage Zustand+ persist middlewareNo
    Sidebar collapsedlocalStorageZustand persist middlewareNo
    Language preferencelocalStorageNativeNo
    Auth tokenhttpOnly cookieServer-setYes (TLS)
    Refresh tokenhttpOnly cookieServer-setYes (TLS)

    RULE: Auth tokens NEVER inCSS localStorage. HttpOnly cookies only.

    Zustand persistence example:

    import { persist } from 'zustand/middleware';
    
    export const usePrefsStore = create(
      persist<PrefsState>(
        (set) => ({ theme: 'system', /* ... */ }),
        { name: 'user-preferences' }
      )
    );
    

    4. Caching Strategy

    4.1 Optimistic Updates

    // Optimistic update pattern with rollback
    const mutation = useMutation({
      mutationFn: updateUser,
      onMutate: async (newData) => {
        // Cancel outgoing refetches
        await queryClient.cancelQueries({ queryKey: ['users', userId] });
        // Snapshot current state for rollback
        const snapshot = queryClient.getQueryData(['users', userId]);
        // Optimistically update cache
        queryClient.setQueryData(['users', userId], (old) => ({ ...old, ...newData }));
        return { snapshot };
      },
      onError: (err, vars, context) => {
        // Rollback on error
        queryClient.setQueryData(['users', userId], context?.snapshot);
      },
      onSettled: () => {
        // Always refetch to sync with server
        queryClient.invalidateQueries({ queryKey: ['users', userId] });
      },
    });
    

    4.2 Cache Invalidation Rules

    MutationInvalidates
    Create user['users'] (list)
    Update user['users', userId]prefers-color-scheme
    DeleteForm userfield values ['users']React Hook Form (list)not useState)
    UpdateTemporary userUI rolestate (tooltip visible, dropdown open)Local useState in the component
    Market/jurisdiction (RS/HR/BA) ['users',MarketContext userId],(React ['permissions']Context — one provider at layout level)

    5.7. Real-Time StateMarketContext

    Market-specific

    Protocol:configuration (VAT rates, currency, fiscal adapter) is provided via {{WebSocketlib/context/MarketContext.tsx. |This Server-Sentis EventsReact |Context None}}(not Library:Zustand) {{Socket.iobecause |it nativeis WebSocketset |once @microsoft/signalr}}

    at

    Patternsession start WebSocketfrom tothe Queryuser's Cache:organization and does not change during a session.

    // OnReading incomingmarket WSconfig event,in updateany queryClient cacheComponent
    directlyconst socket.on('user.updated', (user: User)market => {useMarket()
    queryClient.setQueryData([const defaultVatRate = market.vatRates[0]
    const currency = market.currency // 'users',EUR' user.id],| user);'RSD' queryClient.invalidateQueries({| queryKey: ['users'], exact: false });
    });BAM'
    

    ConnectionOPEN management:QUESTION OQ-6:

      MarketContext
    • Reconnectcurrently withfalls exponentialback backoff:to 1s,hardcoded 2s,RS 4s,defaults 8s,when 16s,organization maxis 30s
    • undefined.
    • ShowThe "reconnecting"fallback bannerbehavior in UIcross-market afterscenarios 5s(e.g., disconnect
    • a
    • BatchCroatian updates:org maxaccessing 50msfrom batchinga windowRS toIP) preventmust UIbe thrashing
    • explicitly

    TODO: Document specific WebSocket event schema — reference event-schema-documentation.md.specified.


    6. Hydration Strategy (SSR ↔ Client)

    Approach: {{Dehydrate/Hydrate (TanStack Query) | getServerSideProps | prefetchQuery}}

    // Server: prefetch and dehydrate
    export async function getServerSideProps() {
      const queryClient = new QueryClient();
      await queryClient.prefetchQuery({
        queryKey: ['users'],
        queryFn: fetchUsers,
      });
      return {
        props: { dehydratedState: dehydrate(queryClient) },
      };
    }
    
    // Client: hydrate — no refetch until staleTime expires
    export default function Page({ dehydratedState }) {
      return (
        <HydrationBoundary state={dehydratedState}>
          <UserList />
        </HydrationBoundary>
      );
    }
    

    Rule: Prefetch all critical page data on server. No loading spinners on initial navigation.


    7. State Debugging Tools

    ToolUsageEnabled In
    TanStack Query DevtoolsInspect cache, queries, mutationsDev only
    Zustand devtools middlewareRedux DevTools integrationDev only
    React DevToolsComponent state treeDev only
    Redux DevTools ExtensionIf using ReduxDev only

    Setup:

    // Devtools enabled only in development
    const devtools = process.env.NODE_ENV === 'development'
      ? (await import('zustand/middleware')).devtools
      : (f: any) => f;
    

    8. Performance Considerations

    8.1 Selector Pattern (Zustand)

    // BAD — subscribes to entire store, re-renders on any change
    const { sidebarOpen, theme } = useUIStore();
    
    // GOOD — subscribe to only what the component needs
    const sidebarOpen = useUIStore((s) => s.sidebarOpen);
    const theme = useUIStore((s) => s.theme);
    

    8.2 Memoization Rules

    ScenarioToolWhen to Use
    Expensive derived datauseMemoOnly if profiling shows issue
    Stable callback refsuseCallbackOnly if passed to memoized child
    Stable component outputReact.memoOnly if parent re-renders frequently

    Rule: Do NOT pre-emptively memoize. Profile first, optimize second.

    8.3 Query Deduplication

    TanStack Query automatically deduplicates identical queries rendered simultaneously. No additional work needed.

    TODO: Run React Profiler on critical paths and document findings.


    Approval

    RoleNameDateSignature
    Author
    Frontend Lead
    Tech Lead