Skip to main content

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.0 Date:Future State: {{DATE}}Migrate Author:to {{AUTHOR}}Zustand Status:for Draft | In Review | Approved Reviewers: {{REVIEWERS}}

Document History

VersionDateAuthorChanges
0.1{{DATE}}{{AUTHOR}}Initial draft

1. State Architecture Overview

{{PROJECT_NAME}} uses a layeredglobal 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.Current State CategoriesPatterns

3.1Local ServerComponent State (APIReact Data)useState)

Library:Usage: {{TanStackMost Querycomponents v5}}use local state for UI interactions and form data.

Examples:

Configuration:

Dashboard
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:

const 
[statusFilter, setStatusFilter] = const setSearchQuery] = useState<string>('') const [dateRange, setDateRange] useState<string>('this-month') [sortColumn, days.") [emailData, setEmailData] = Layout:

[sidebarOpen, = useState(false)

Sidebar:

setExpandedSections]useState<string[]>(['Purchases',])
ResourceStaleuseState<string>('all') TimeGC[searchQuery, TimeRationale
User= profile5const minsetSortColumn] = useState<string>('date') const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')

Invoice Wizard:

const [step, setStep] = useState(1)
const [customer, setCustomer] = useState<Contact | null>(null)
const [showAddCustomer, setShowAddCustomer] = useState(false)
const [invoiceDetails, setInvoiceDetails] = useState<InvoiceDetails>({...})
const [lineItems, setLineItems] = useState<LineItem[]>([...])
const [notes, setNotes] = useState("Thank you for your business!")
const [terms, setTerms] = useState("Payment due within 30 min
Changesconst infrequently
useState({...})

Expenses Page:

const [isDialogOpen, setIsDialogOpen] = useState(false)
const [periodFilter, setPeriodFilter] = useState("This Month")
const [categoryFilter, setCategoryFilter] = useState("All Categories")
const [searchQuery, setSearchQuery] = useState("")
const [formData, setFormData] = useState({...})

Banking Page:

const [selectedAccount, setSelectedAccount] = useState(mockBankAccounts[0].id)

Reports Page:

const [plExpanded, setPlExpanded] = useState({
  revenue: true,
  expenses: true,
})

VAT Report:

const [currentStep, setCurrentStep] = useState<'reconciliation' | 'audit' | 'summary'>(
  'reconciliation',
)

Settings Page:

const [activeSection, setActiveSection] = useState('company')
const [companyData, setCompanyData] = useState({...})
const [vatSettings, setVatSettings] = useState({...})

Dashboard stats

30
const sec
5setSidebarOpen] minNear-realtime
Config
const /[expandedSections, enums
1= hour 24'Sales', hours Rarely'Reports', changes
Notifications0 (always fresh)2 minTime-sensitive

3.2 ClientComputed State (UIReact State)useMemo)

Library:Purpose: {{Zustand}}Derived values from props/state to avoid expensive recalculations.

StoreInvoice slices:

SliceFileResponsibility
uiStoresrc/stores/ui.store.tsSidebar open, active modal, theme
authStoresrc/stores/auth.store.tsCurrent user, roles, session token
notificationStoresrc/stores/notification.store.tsToast queue, unread count
{{featureStore}}src/stores/{{feature}}.store.ts{{Feature-specific UI state}}

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)

Sidebar:

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

  1. Phase 2a: Create stores with API integration
  2. Phase 2b: Replace useState with store hooks in components
  3. Phase 2c: Add optimistic updates and caching
  4. 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 queries
  • Pagination (page, pageSize)
  • Sort column and direction
  • Active tab / view mode
  • Modal 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 store
  • Schema validation lives in src/schemas/ — reused for API validation
  • Complex multi-step forms use form context + Stepper component

3.5 Persistent State

DataStorageLibraryEncryption
Theme preferencelocalStorageZustand persist middlewareNo
Sidebar collapsedlocalStorageZustand persist middlewareNo
Language preferencelocalStorageNativeNo
Auth tokenhttpOnly cookieServer-setYes (TLS)
Refresh tokenhttpOnly cookieServer-setYes (TLS)

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

MutationInvalidates
Create user['users'] (list)
Update user['users', userId]
Delete user['users'] (list)
Update user role['users', userId], ['permissions']

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:

  • ReconnectReact withhooks exponential(useState, backoff:useMemo, 1s, 2s, 4s, 8s, 16s, max 30suseEffect)
  • ShowMock "reconnecting"data banner in UI after 5s disconnectimports
  • BatchLocal updates:component maxstate
  • 50ms
  • No batchingpersistence
  • window
  • No toglobal preventstate
  • UI
  • 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

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: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

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