Skip to content

React Conventions

These are the non-negotiable conventions for the React back-office app. They exist because each concern has exactly one correct mechanism — hand-rolling a second one is the bug these rules prevent.

Loading and empty states — one global channel

Section titled “Loading and empty states — one global channel”

Every view (List, Kanban, Form, Calendar, Pivot, Chart, dashboards, reports, …) uses one loading system. Do not instantiate a view-level loader yourself.

app/src/contexts/LoadingContext.jsxLoadingProvider renders the only loading UI in the app:

  • the one InfiniteLoader top bar (short loads), and
  • the one centered FullfinityIconAnimated logo overlay, which escalates after ~1.5s (LONG_LOADING_DELAY) for the slow-connection case.

The provider has two inputs with deliberately different reach:

  • Foreground loads — registered via useRegisterLoading / AsyncBoundary (plus the navigation transition): drive the bar and, after 1.5s, the blocking logo overlay.
  • React Query background fetchinguseIsFetching(): drives the bar only, never the blocking logo. This is the anti-freeze rule: a 30s poll (e.g. useMetrics) or a hung request must never be able to freeze the UI behind the dim overlay.

Local-state views (a view holding its own useState loading flag — e.g. ActivityStream, PosRegister) wrap content in AsyncBoundary:

import AsyncBoundary from '@/components/ui/misc/AsyncBoundary';
<AsyncBoundary
loading={isLoading}
empty={items.length === 0}
emptyProps={{ icon: 'Inbox', title: t('Nothing here yet'), message: t('Create your first record.') }}
>
{/* content */}
</AsyncBoundary>

AsyncBoundary registers the load into the global channel (useRegisterLoading) and renders no spinner itself — it only decides between the shared EmptyState and the children. This makes the circular-spinner anti-pattern structurally impossible.

React-Query views already feed the global bar via useIsFetching, so they do not add AsyncBoundary for loading (that would double-count) — they just render an EmptyState when empty.

To drive the channel directly (rare), use the hook:

import { useRegisterLoading } from '@/contexts/LoadingContext';
useRegisterLoading(isLoading); // increments while true, decrements on cleanup

app/src/components/ui/misc/EmptyState.jsx is the one empty-state component. Use the compact prop for in-panel / “select something” prompts (master-detail panels); the default (large icon in a tinted circle) is for a whole-view empty. A grid that is valid-but-empty (e.g. an empty calendar that still shows the grid) and a record Form do not get an empty state.

import { EmptyState } from '@/components/ui/misc/EmptyState';
<EmptyState icon="FileX2" title={t('No invoices')} message={t('Create one to get started.')} />
<EmptyState compact title={t('Select a record')} message={t('Pick a row to see details.')} />

The circular Mantine <Loader/> is scope-gated, not banned: never as a main-view, fullscreen, or blocking loader (use the channel above). A small inline <Loader size="xs|sm"> for a localized sub-region — a dashboard widget card, a kanban column footer, a list-group pagination spinner, a planning-grid section — is correct.

Theming and dark mode — theme-aware variables only

Section titled “Theming and dark mode — theme-aware variables only”

The app’s Mantine theme lives in app/src/theme.js (createTheme + a cssVariablesResolver that fixes derived variables per color scheme). Because the app is a monochrome theme that inverts in dark mode (primaryColor: "gray", dark buttons in light mode / white buttons in dark mode), never hardcode colors — always use theme-aware CSS variables or light-dark().

Don’t useUse instead
#ffffff, #fffvar(--mantine-color-body)
var(--mantine-color-gray-0) for backgroundsvar(--mantine-color-body) or light-dark(...)
var(--mantine-color-gray-3) for bordersvar(--mantine-color-default-border)
a fixed text colorvar(--mantine-color-text) / c="dimmed"
// Component-level scheme-aware value
<Box bg="light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6))" />

EmptyState, for example, builds its circle from var(--mantine-primary-color-light) and its icon from var(--mantine-primary-color-filled) — copy that pattern rather than picking a hex.

Internationalization — the English text IS the key

Section titled “Internationalization — the English text IS the key”

The whole translation pipeline is keyed by the English source text (the same rule as the backend). In React, get the translator from TranslationContext and pass the English string as the first argument to t():

import { useT } from '@/contexts/TranslationContext';
const t = useT();
t('Save Changes'); // CORRECT — key + fallback in one
t('Saved {count} records', { count }); // CORRECT — interpolation params
t('save_changes', 'Save Changes'); // WRONG — synthetic key never resolves

t(key, defaultTextOrParams, params) looks up translations[key], falling back to the default text and then to the key itself. Passing an object as the second argument supplies interpolation values ({name} placeholders); the legacy (key, "Default") signature is tolerated for safety but new call sites must use the English text as the key. The useTranslation hook also exposes language, locale, formatNumber, activeLanguageCount and cache controls.

The translator is conventionally const t = useT(). Do not call t('…') inside a callback whose own parameter is also named t (e.g. items.map((t, i) => … t('Label') …)) — that invokes the loop item, not the translator, and throws “t is not a function”. Rename the loop variable.

When a utility accepts an injected t (for example handleActionResult), its default must follow English-as-key — (key, fallback) => fallback ?? key, not => fallback. That way an un-injected call still returns the English string instead of undefined.