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.
The single loading channel
Section titled “The single loading channel”app/src/contexts/LoadingContext.jsx → LoadingProvider renders the only loading UI in
the app:
- the one
InfiniteLoadertop bar (short loads), and - the one centered
FullfinityIconAnimatedlogo 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 fetching —
useIsFetching(): 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.
How a view feeds it
Section titled “How a view feeds it”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 cleanupEmpty → always the shared EmptyState
Section titled “Empty → always the shared EmptyState”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.')} />When a circular <Loader/> is allowed
Section titled “When a circular <Loader/> is allowed”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 use | Use instead |
|---|---|
#ffffff, #fff | var(--mantine-color-body) |
var(--mantine-color-gray-0) for backgrounds | var(--mantine-color-body) or light-dark(...) |
var(--mantine-color-gray-3) for borders | var(--mantine-color-default-border) |
| a fixed text color | var(--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 onet('Saved {count} records', { count }); // CORRECT — interpolation paramst('save_changes', 'Save Changes'); // WRONG — synthetic key never resolvest(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 t-shadowing pitfall
Section titled “The t-shadowing pitfall”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.
Injected-translator defaults
Section titled “Injected-translator defaults”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.