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.
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/)
1. State Architecture Overview
{{PROJECT_NAME}} uses a layered state management approach:
| ||
| ||
| ||
|
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:
3.2 Client State (UI State)
Library: {{Zustand}}
Store slices:
|
metrics, monthlyPL, receivablesAging, recentTransactions |
/api/v1/dashboard |
|
invoices, pagination, filters |
/api/v1/invoices |
|
expenses |
/api/v1/expenses |
|
contacts | Fetches from |
useBankingStore |
Fetches from /api/v1/banking |
|
useAuthStore |
user, organization, token | Auth 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:
// 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 filterqueriesvalues, Paginationsort(page,order,pageSize)Sort columnpagination, anddirectionany Activestatetabthat/shouldviewsurvivemodea ModalpageIDrefresh(whenordeep-linkable)be
Convention:
/users?page=2&pageSize=25&search=john&sort=name&dir=asc&status=active
Library: Native URLSearchParams + router useSearchParams hook
Serialization helper: link.src/lib/url-state.ts
// 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 Form5. State
Lifting Library:Rules
- Start with
{{React Hook Form v7}}useStateValidation:inthe{{Zod}}
lowestPattern:constpossibleschemacomponent. - Lift
z.object({toemail:parentz.string()only when a sibling needs the same state. - Move to Zustand only when lifting would require passing through 3+ component levels (prop drilling).
email('Invalid - Move
name:toz.string().min(2,URL'Namestatemustwhen the value should beatbookmarkable,leastshareable,2orcharacters'),survive}); const form = useForm<z.infer<typeof schema>>({ resolver: zodResolver(schema), defaultValues: { email: '', name: '' }, });Rules:Form state NEVER leaks into global storerefresh.SchemaMovevalidationtolivesRSC + 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
3.5 Persistent State
| Data | |||
|---|---|---|---|
httpOnly cookie (set by server, read by Next.js middleware) |
|||
| Locale preference | Accept-Language header or locale route segment |
||
| Dark mode preference | localStorage | ||
| |||
| |||
| |||
|
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
| |
|
|
React Hook Form ( |
|
Local useState in the component |
|
| Market/jurisdiction (RS/HR/BA) | 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}}
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'
ConnectionOPENmanagement:QUESTION OQ-6:
MarketContext- undefined.
Reconnectcurrentlywithfallsexponentialbackbackoff:to1s,hardcoded2s,RS4s,defaults8s,when16s,organizationmaxis30s- a
ShowThe"reconnecting"fallbackbannerbehavior inUIcross-marketafterscenarios5s(e.g.,disconnect- explicitly
BatchCroatianupdates:orgmaxaccessing50msfrombatchingawindowRStoIP)preventmustUIbethrashing
TODO:Document specific WebSocket event schema — referenceevent-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
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
| ||
| ||
|
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.