Skip to content

Frontend Development Patterns

This document defines the recurring patterns and conventions used in the Patrimoine frontend.


All server state is managed through TanStack Query. Hooks follow a consistent structure:

  • queryKey — Array describing the data (resource + parameters)
  • queryFn — Calls apiFetch with the API endpoint
  • staleTime — Controls refetch behavior per resource type
  • enabled — 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,
})

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.

  • Query keys are arrays starting with the resource name, followed by sub-resources and parameters
  • staleTime is set per hook based on data volatility (charts: 60s, accounts: 30s, etc.)
  • Hooks are colocated in src/hooks/ and named use<Resource>.ts
  • Delete mutations (useDeleteInstitution, useDeleteHolding) accept optional { onSuccess?, onError? } callbacks for toast notifications.

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.

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')} />

List item components (AccountCard, SnapshotRow, SnapshotGridCell) are wrapped in React.memo; see Component Library.


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.


Route-level code splitting uses React.lazy + Suspense:

const ChartsPage = lazy(() => import('./pages/ChartsPage'))
<Suspense fallback={<PageLoader />}>
<ChartsPage />
</Suspense>

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.


  • Per-route — Each route has an errorElement using the RouteErrorFallback component, isolating errors to the affected route.
  • App-level — An ErrorBoundary wraps the entire application as a last-resort catch.
  • Auto-reset — ErrorBoundary resets its error state (hasError: false) when the children prop changes, so navigating to a different route automatically clears the error without requiring a manual page refresh.

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:

  1. errors.network when the request was offline
  2. The call’s own errorCodeMap (backend error code to message id); keep an entry only where the dialog’s copy genuinely differs
  3. errors.api.<code> for the backend’s structured error code
  4. The status key (errors.unauthorized, errors.forbidden, errors.notFound, errors.conflict, errors.validation, or errors.server for a 5xx)
  5. 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.


A list page shows one of three states, checked in this order:

  1. ErrorState when the query failed
  2. EmptyState when there is no data at all, with a call to action
  3. NoResultsState when data exists but the search or filters match none of it; its onClear resets them

Never show an empty state for a failed load.


  • ErrorState (src/components/ui/ErrorState.tsx) — shared error state with AlertCircle icon + retry button; sizes page, compact and inline, and Retry waits while an onRetry promise is pending
  • LoadingSpinner (src/components/ui/LoadingSpinner.tsx) — accessible loading indicator with role="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, parseLocaleNumber or parseStrictLocaleNumber, 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 storedNumberDraft first, since “3.500” reads as 3500 in de; an already normalized string is parsed with parseFloat or Number, 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’s aria-describedby at it, and rewrite the text on blur with reformatNumberDraft so the user sees how the amount was read.
  • Calendar dates — use toLocalISODate from src/lib/format.ts, never a cut of toISOString(), which is UTC and shifts the day near midnight (a drift guard enforces it)

DnD is implemented with @dnd-kit/core and @dnd-kit/sortable.

  • SortableGrid — Reusable grid container that manages sortable context
  • DragOverlay — Renders a floating copy of the dragged item for visual feedback
<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} />}
/>

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.


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.


src/lib/format.ts holds the locale-aware formatters (formatCurrency, formatDateShort, formatDateLong, formatPercent, compareText and the amount parsers). The function table is in Internationalization.


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.