State Management
Bilko State Management Architecture
Project:Current State:{{PROJECT_NAME}}Primarily React hooks (useState, useEffect)Version:Installed but Minimal Use:{{VERSION}}Zustand 4.5.0Date:Future State:{{DATE}}MigrateAuthor:to{{AUTHOR}}ZustandStatus:forDraft | In Review | ApprovedReviewers:{{REVIEWERS}}
Document History
1. State Architecture Overview
{{PROJECT_NAME}} uses a layeredglobal 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.Current State CategoriesPatterns
3.1Local ServerComponent State (APIReact Data)useState)
Library:Usage: use local state for UI interactions and form data.{{TanStackMost Querycomponents v5}}
Examples:
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:Page:
// HierarchicalNo keyslocal forstate precise— invalidationpurely ['users']presentational, //uses allmock userdata queries
['users', { page: 1, search: '' }] // paginated user list
['users', userId] // single user
['users', userId, 'posts'] // user's postsimports
StaleInvoice timeList per resource type:Page:
| setSortColumn] = useState<string>('date')
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')
Invoice Wizard: | days.")
|||
| useState({...})
Expenses Page:
Banking Page:
Reports Page:
VAT Report:
Settings Page:
Dashboard | Layout:
| [sidebarOpen, ||
| setExpandedSections] |||
3.2 ClientComputed State (UIReact State)useMemo)
Library:Purpose: Derived values from props/state to avoid expensive recalculations.{{Zustand}}
StoreInvoice slices:
| | |
| | |
| | |
| |
Slice template:List:
// src/stores/ui.store.tsFiltered/sorted invoice list
const filteredInvoices = useMemo(() => {
let filtered = [...mockInvoices]
// Apply filters
if (statusFilter !== 'all') {
filtered = filtered.filter((inv) => inv.status === statusFilter)
}
if (searchQuery) {
const query = 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])
Invoice Wizard:
// Customer list
const customers = useMemo(() => mockContacts.filter((c) => c.type === 'customer'), [])
// Calculate totals
const totals = useMemo(() => {
const subtotal = lineItems.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0)
const vatTotal = lineItems.reduce(
(sum, 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])
// Stats
const stats = useMemo(() => {
const total = 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 }
}, [filteredExpenses])
Banking 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)
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 handleNext = () => {
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 { createmockInvoices, mockExpenses, mockBankAccounts } from 'zustand';@/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;boolean
activeModal:sidebarExpandedSections: string | null;string[]
theme: 'light' | 'dark'
| 'system';
}
interface UIActions {
setSidebarOpen: (open: boolean) => void;
openModal: (id: string) => void;
closeModal:toggleSidebar: () => void;void
toggleSection: (section: string) => void
setTheme: (theme: UIState['theme']light' | 'dark') => void;void
}
Migration Strategy
- Phase 2a: Create stores with API integration
- Phase 2b: Replace useState with store hooks in components
- Phase 2c: Add optimistic updates and caching
- 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 res = await fetch(`${API_BASE}${endpoint}`, {
headers: { Authorization: `Bearer ${getToken()}` },
})
if (!res.ok) throw new Error(await res.text())
return res.json()
},
post: async (endpoint: string, data: any) => {
const res = await fetch(`${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')
}
}
State Persistence (Future)
UI Preferences: localStorage Auth Token: httpOnly cookie (secure) Data Cache: sessionStorage (optional, for performance)
// Example: Persist sidebar state (future)
export const useUIStore = create<UIState & UIActions>((set) => ({
sidebarOpen: true,
activeModal: null,
theme: 'system',
setSidebarOpen: (open) => set({ sidebarOpen: open }),
openModal: (id) => set({ activeModal: id }),
closeModal: () => set({ activeModal: null }),
setTheme: (theme) => set({ theme }),
}));
3.3 URL State
URL state is the source of truth for:
Search / filter queriesPagination (page, pageSize)Sort column and directionActive tab / view modeModal ID (when deep-linkable)
Convention:
/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.ts
// Type-safe URL param parsing with fallbacks
export function parseListParams(params: URLSearchParams): ListParams {
return {
page: Number(params.get('page') ?? 1),
pageSize: Number(params.get('pageSize') ?? 25),
search: params.get('search') ?? '',
sort: params.get('sort') ?? 'createdAt',
dir: (params.get('dir') as 'asc' | 'desc') ?? 'desc',
};
}
3.4 Form State
Library: {{React Hook Form v7}}
Validation: {{Zod}}
Pattern:
const schema = z.object({
email: z.string().email('Invalid email'),
name: z.string().min(2, 'Name must be at least 2 characters'),
});
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: { email: '', name: '' },
});
Rules:
Form state NEVER leaks into global storeSchema validation lives insrc/schemas/— reused for API validationComplex multi-step forms use form context + Stepper component
3.5 Persistent State
| |||
| |||
| |||
| |||
|
RULE: Auth tokens NEVER in localStorage. HttpOnly cookies only.
Zustand persistence example:
import { persist } from 'zustand/middleware';
export const usePrefsStore = create(
persist<PrefsStateUIState>(
(set) => ({
theme:sidebarOpen: 'system'false,
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })), /* ... */
}),
{ name: 'user-bilko-ui-preferences' },
),
);
4. Caching StrategySummary
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
| |
| |
| |
|
5. Real-Time State
Protocol:Current {{WebSocket | Server-Sent Events | None}}
Library: {{Socket.io | native WebSocket | @microsoft/signalr}}
Pattern — WebSocket to Query Cache:
// On incoming WS event, update query cache directly
socket.on('user.updated', (user: User) => {
queryClient.setQueryData(['users', user.id], user);
queryClient.invalidateQueries({ queryKey: ['users'], exact: false });
});
Connection management:State:
ReconnectReactwithhooksexponential(useState,backoff:useMemo,1s, 2s, 4s, 8s, 16s, max 30suseEffect)ShowMock"reconnecting"databanner in UI after 5s disconnectimportsBatchLocalupdates:componentmaxstate- No
batchingpersistence - No
toglobalpreventstate - Zustand
thrashinginstalled but unused
TODO: Document specific WebSocket event schema — reference event-schema-documentation.md.
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.Future State Debugging(Phase Tools
Setup:2):
//
Devtools- Zustand
enabledstores onlyfor inglobal developmentstate
const- API
devtoolsintegration =layer
process.env.NODE_ENV- Loading/error
===states
'development'- Optimistic
?updates
- State persistence (
awaitUI import('zustand/middleware')).devtoolsprefs)
:- JWT
(f:token any)management
=>
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.