Skip to content

Data Layer

The React back-office app talks to the backend through one TanStack Query layer and one shared axios client. All data fetching goes through hooks in app/src/data/hooks/; all HTTP goes through app/src/axiosInstance.js. Do not call fetch/axios ad hoc or stand up a second query client.

DataProvider (app/src/data/DataProvider.jsx) wraps the app in TanStack Query’s QueryClientProvider. It is mounted high in the tree (around the router in App.jsx).

The shared QueryClient (app/src/data/queryClient.js) is configured for always-fresh data rather than aggressive caching:

queries: {
staleTime: 0, // immediately stale — refetch on mount
gcTime: 0, // no retained cache — GC immediately
retry: 1, // retry a failed query once
refetchOnWindowFocus: false, // don't refetch on tab focus
refetchOnReconnect: true, // refetch when the network returns
refetchOnMount: true, // always refetch when a component mounts
}
mutations: { retry: 0 } // never retry a mutation

Because queries are always stale and not retained, the cache primarily serves deduplication and invalidation during a session rather than long-lived offline reads.

app/src/data/keys.js centralizes every cache key in a queryKeys factory so that invalidation stays consistent. Highlights:

queryKeys.query(model, params) // ['query', model, params] — list/record queries
queryKeys.model(model) // ['query', model] — prefix for bulk invalidation
queryKeys.record(model, id) // ['record', model, id] — a single record
queryKeys.chart(model, params) // per-view-type families: chart/pivot/calendar/gantt/orgchart
queryKeys.view(viewId) // a fetched view definition
queryKeys.profile() // ['profile']
queryKeys.navMenu() // ['navMenu']
// ...plus files, activityStream, unreadCount, metrics, databases, etc.

A companion normalizeQueryParams(...) sorts/normalizes filter, grouping, ordering and aggregate params so that two semantically identical requests produce the same cache key regardless of argument order. Mutations invalidate by prefix — e.g. after a create the mutation hooks call invalidateQueries({ queryKey: queryKeys.model(model) }), which clears every query for that model.

Exported from app/src/data/hooks/index.js. They split into generic primitives, view-specific fetchers, and record operations.

HookPurposeReturns (selected)
useModelQuery({ model, filter, fields, page, limit, orderBy, groupFields, ... })The generic record query (Q-expression filter string, paging, grouping, aggregates){ data, isLoading, isFetching, error, refetch }
useModelMutation(model, { onSuccess, onError, invalidateOnSuccess })create / update / delete with auto cache invalidationmutation fns + states
useExecuteMethod({ onSuccess, onError, invalidateOnSuccess })Call a backend model method{ execute, mutate, isExecuting, error, reset }
useGetRecord({ model, id, fields, enabled })Fetch one record declaratively{ data, isLoading, isFetching, isError, error, refetch }
useGetRecordMutation({ onSuccess, onError })Fetch one record imperatively (event handlers/effects){ getRecord, isFetching, error, reset }
useUiEffect({ onSuccess, onError })Trigger server-side UI effects (onChange / onDefault) for live forms{ trigger, isTriggering, error, reset }

These wrap useModelQuery and wire it to the FilterContext / PaginationContext / NavigationContext so the view’s filter, grouping, search and paging flow in automatically:

useListData, useKanbanData, useChartData, usePivotData, useCalendarData, useGanttData, useOrgChartData, useFilterPanelData.

const { data, count, isLoading, isFetching, error, refetch } = useListData({
actionData, // action with model + views + global_filter
fields, // fields from the list view
groupAggregates, // [{ field, type }] for grouped aggregates
footerAggregates, // [{ field, type }] for whole-dataset footers
});
HookPurpose
useArchive(model, { onSuccess, onError }){ archive, unarchive, isArchiving, ... }
useUnarchiveUnarchive-only variant
useDeleteRecords({ onSuccess, onError })Bulk delete: { deleteRecords, isDeleting, ... }
useCloneRecordDuplicate a record
useResequenceReorder records (drag-sort)

There are also focused hooks for files (useGetFiles, useUploadFile, …), views/actions (useFetchView, useLoadAction), wizards (useTransientDefaults, useExecuteWizard), messaging (useSendMessage, useManageFollowers), export/import (useExportData, useImport), search (useGlobalSearch, useNameSearch), reports (useGenerateReport, useRenderTemplate), the activity stream (useActivityStream, useUnreadCount), and profile/admin concerns (useProfile, useNavMenu, useLogout, useMetrics, useDatabases, …). See app/src/data/hooks/index.js for the full export list.

app/src/axiosInstance.js exports the single configured client (baseURL = the current origin, withCredentials: true). Every API call routes through it so that cross-cutting behavior is applied once.

  • Stamps config._requestStart and dispatches an api-request-start window event.
  • Adds X-Current-URL (the current page), X-DB-NAME (from the db= cookie), and X-Company-IDs (from selectedCompanies in localStorage) for multi-company filtering.

Server timing. On both success and error it computes a request duration — preferring the server-reported Server-Timing: dur=<ms> header over the client round-trip — and dispatches an api-request-end event with { duration } (used by the dev timing display).

Enterprise license gate. A 403 whose body carries code: "ENTERPRISE_LICENSE_REQUIRED" does not raise a generic toast. Instead it dispatches an enterprise-license-required window event, which the shared UpgradeDialog listens for and opens (license-key entry / purchase / uninstall). This is the front-end half of the enterprise gating mechanism.

Token refresh + re-auth queue. A 401 with code / detail of TOKEN_INVALID_OR_EXPIRED triggers automatic recovery:

  1. While a refresh is in flight (isRefreshing), concurrent failing requests are parked in a failedQueue (a promise per request) instead of each firing its own refresh.
  2. The interceptor POSTs the refresh_token cookie to /auth/refresh. On success the queue is drained and the original request is retried.
  3. If refresh fails and a re-auth handler was registered via setReAuthHandler(...), it runs in-app re-authentication; on success the queue drains and the request retries, on dismissal it rejects the queue and redirects to /login.
  4. With no re-auth handler, it shows a “Session Expired” toast and redirects to /login.

Generic error toast. Other errors (with a response, not flagged skipErrorToast, and not Import Error) are passed to the registered error handler (setErrorHandler(...)) with title, message, traceback, status and an optional actionable action. Requests marked skipErrorToast (e.g. background offline sync) fail silently, and a network failure with no error.response (offline) deliberately does not raise the generic toast.

When a backend method returns an action result (the common case for action buttons and wizards), the response is dispatched by handleActionResult in app/src/utils/actionResultHandler.jsx. It takes the result plus an options bag (t, reloadView, pushModal/popModal, setActionResponse, activeId/activeIds/ activeModel, buttonLabel, shouldReload, …) and branches on result.type:

result.typeBehavior
file_blob / pdf_blobDownload the binary via an object URL; no reload
file_downloadDownload a base64 payload (content + mimetype); no reload
wizardLazy-load WizardModal and pushModal it — drawer for single-step, fullscreen modal when the arch has a stepper; merges active_model / active_ids into the wizard context; on complete, dispatches a returned action or reloads
window_actionsetActionResponse(result) (with active_id) → navigation/view switch
send_messageLazy-load SendMessageModal and open it (message / note / invite-followers)
urlOpen in a new tab (target: '_blank') or navigate the current window
closeOptionally toast notify, optionally reload
reloadToast success and reload the view
notifyShow a notification (color, title, message); if it carries an action, render an actionable button that deep-links via setActionResponse
reportGenerate a report via the report API
(none / unknown)Toast a generic success and reload if shouldReload

Notifications are raised through Mantine’s notifications.show(...), and all user-facing strings pass through the injected t (whose default follows English-as-key: (key, fallback) => fallback ?? key). The executeMethod flow (via useExecuteMethod) calls into this handler so that a single backend return value drives downloads, wizards, navigation, notifications, close and reload consistently.