Frontend Development Patterns
This document defines the recurring patterns and conventions used in the Patrimoine frontend.
Data Fetching — TanStack Query Hooks
Section titled “Data Fetching — TanStack Query Hooks”All server state is managed through TanStack Query. Hooks follow a consistent structure:
queryKey— Array describing the data (resource + parameters)queryFn— CallsapiFetchwith the API endpointstaleTime— Controls refetch behavior per resource typeenabled— Optional{ enabled?: boolean }parameter on lazy-loaded queries (e.g. chart and savings-goal hooks) to defer fetching until the relevant UI tab is active
Example from useCharts.ts:
useQuery({ queryKey: ['charts', 'net-worth', params], queryFn: () => apiFetch('/api/v1/charts/net-worth'), staleTime: 60_000,})Lazy Loading via enabled
Section titled “Lazy Loading via enabled”Chart hooks and useSavingsGoalsQuery accept an optional options?: { enabled?: boolean } second parameter. ChartsPage uses this to fetch data only for the active tab, avoiding unnecessary API calls for hidden charts:
const { data } = useStackedByCategoryQuery( { from: dateRange.from, to: dateRange.to }, { enabled: activeTab === 'stacked-category' });The useTransactionsQuery hook also supports the enabled option, used by ChartsPage tracking tab.
Conventions
Section titled “Conventions”- Query keys are arrays starting with the resource name, followed by sub-resources and parameters
staleTimeis set per hook based on data volatility (charts: 60s, accounts: 30s, etc.)- Hooks are colocated in
src/hooks/and nameduse<Resource>.ts - Delete mutations (
useDeleteInstitution,useDeleteHolding) accept optional{ onSuccess?, onError? }callbacks for toast notifications.
Component Patterns
Section titled “Component Patterns”shadcn/ui Base Components
Section titled “shadcn/ui Base Components”UI primitives are built on shadcn/ui (base-nova variant) and live in src/components/ui/. These are owned by the project and can be customized directly.
className Merging
Section titled “className Merging”Use the cn() helper from src/lib/utils.ts to merge Tailwind classes conditionally:
import { cn } from '@/lib/utils'
<div className={cn('base-class', condition && 'conditional-class')} />React.memo on List Items
Section titled “React.memo on List Items”List item components (AccountCard, SnapshotRow, SnapshotGridCell) are wrapped in React.memo; see Component Library.
Internationalization (i18n)
Section titled “Internationalization (i18n)”The app uses react-intl with five locales (fr, en, es, de, it). <FormattedMessage> and useIntl() are the two entry points, Zod errors carry i18n keys, and counts use ICU plurals. All five catalogs hold the same keys, guarded by locale-parity.test.ts. Catalog layout, detection, the language switcher and the ICU rules are in Internationalization.
Lazy Loading
Section titled “Lazy Loading”Route-level code splitting uses React.lazy + Suspense:
const ChartsPage = lazy(() => import('./pages/ChartsPage'))
<Suspense fallback={<PageLoader />}> <ChartsPage /></Suspense>Route Prefetching
Section titled “Route Prefetching”A routePrefetch map in src/routePrefetch.ts triggers import() on hover/focus, so the chunk is loaded before the user clicks. This gives near-instant navigation for preloaded routes.
Error Boundaries
Section titled “Error Boundaries”- Per-route — Each route has an
errorElementusing theRouteErrorFallbackcomponent, isolating errors to the affected route. - App-level — An
ErrorBoundarywraps the entire application as a last-resort catch. - Auto-reset —
ErrorBoundaryresets its error state (hasError: false) when thechildrenprop changes, so navigating to a different route automatically clears the error without requiring a manual page refresh.
Mutation Errors and Toasts
Section titled “Mutation Errors and Toasts”Mutations run through run() from useMutationToast (src/hooks/useMutationToast.ts). A failure shows one translated error toast and never the backend’s own text, which is English only. The message id resolves in this order:
errors.networkwhen the request was offline- The call’s own
errorCodeMap(backend error code to message id); keep an entry only where the dialog’s copy genuinely differs errors.api.<code>for the backend’s structured error code- The status key (
errors.unauthorized,errors.forbidden,errors.notFound,errors.conflict,errors.validation, orerrors.serverfor a 5xx) errors.unknown
A caller therefore passes no fallback sentence. The import dialogs keep their own result UI and call the exported errorMessageId to resolve a failure the same way. The server’s text lives in serverMessage, defined in api/client.ts; a drift guard fails when anything else reads it.
Toasts always come from @/lib/toast, never from sonner; oxlint’s no-restricted-imports rule fails the lint otherwise. The wrapper sets a duration per type (success 3 s, info 4 s, warning 6 s, error 8 s) because Sonner has a single global duration. Toast text has no trailing period, and count messages are ICU plurals.
List States
Section titled “List States”A list page shows one of three states, checked in this order:
ErrorStatewhen the query failedEmptyStatewhen there is no data at all, with a call to actionNoResultsStatewhen data exists but the search or filters match none of it; itsonClearresets them
Never show an empty state for a failed load.
Shared UI Components
Section titled “Shared UI Components”- ErrorState (
src/components/ui/ErrorState.tsx) — shared error state with AlertCircle icon + retry button; sizespage,compactandinline, and Retry waits while anonRetrypromise is pending - LoadingSpinner (
src/components/ui/LoadingSpinner.tsx) — accessible loading indicator withrole="status"+aria-live="polite" - ChartTabContent (
src/components/charts/ChartTabContent.tsx) — shared wrapper for ChartsPage tab content - SortableTableHead (
src/components/ui/SortableTableHead.tsx) — accessible sortable table header with keyboard support and aria-sort - useBaseCurrency (
src/hooks/useBaseCurrency.ts) — returns user’s base currency; use instead of hardcoding ‘EUR’ - Amount inputs — money is a text input with a decimal keypad, never
type="number"; number inputs are for counts only (a drift guard enforces it)- Typed amounts go through
normalizeLocaleNumberString,parseLocaleNumberorparseStrictLocaleNumber, which read a lone separator in the user’s number format: the grouping separator followed by exactly three digits groups thousands (“1,500” is 1500 in en, “1.500” is 1500 in de/es/it), any other lone separator is a decimal point. Input with both separators (“1,234.56”, “1.234,56”) or spaces (“1 234,56”) reads the same in every format. - Only text the user typed goes through them. A stored dot-decimal value (API data) that prefills an input goes through
storedNumberDraftfirst, since “3.500” reads as 3500 in de; an already normalized string is parsed withparseFloatorNumber, never re-read as user input. CSV and QIF imports carry their own number format and are parsed by the backend. - Put
AmountFormatHint(src/components/ui/AmountFormatHint.tsx) next to a typed amount field, point the field’saria-describedbyat it, and rewrite the text on blur withreformatNumberDraftso the user sees how the amount was read.
- Typed amounts go through
- Calendar dates — use
toLocalISODatefromsrc/lib/format.ts, never a cut oftoISOString(), which is UTC and shifts the day near midnight (a drift guard enforces it)
Drag and Drop (DnD)
Section titled “Drag and Drop (DnD)”DnD is implemented with @dnd-kit/core and @dnd-kit/sortable.
Reusable Components
Section titled “Reusable Components”SortableGrid— Reusable grid container that manages sortable contextDragOverlay— Renders a floating copy of the dragged item for visual feedback
Pattern
Section titled “Pattern”<SortableGrid items={accounts.map((a) => ({ id: String(a.id), account: a }))} onReorder={(reordered) => handleReorder(reordered.map((i) => i.account))} renderItem={(item) => <AccountCard account={item.account} />}/>API Client
Section titled “API Client”All HTTP communication goes through apiFetch, apiUpload and apiDownload in src/api/client.ts. They send cookie credentials and the X-Requested-With header and sanitize error detail. See the API client section of Frontend Architecture.
401 Handler
Section titled “401 Handler”A global handler in src/main.tsx redirects to /login on a 401, except on public routes and for credential errors that the calling form handles itself. TanStack Query uses retry: false for 401 responses to prevent retry loops. Details in Frontend Architecture.
Formatting Utilities
Section titled “Formatting Utilities”src/lib/format.ts holds the locale-aware formatters (formatCurrency, formatDateShort, formatDateLong, formatPercent, compareText and the amount parsers). The function table is in Internationalization.
Bundle Splitting
Section titled “Bundle Splitting”Vite manualChunks splits vendors into recharts, react-vendor, dnd-kit, query, base-ui, icons, date-fns and forms. The contents of each chunk are in Frontend Architecture.