Their stack: Nx, AG Grid, TanStack
The three named tools in the JD where you don't have deep production experience. Over-prepare here. The bar isn't expert — it's "vocabulary fluent and ramps quickly." That's a few hours of reading away.
01Nx — monorepo & micro-frontend tooling
What Nx is, in one minute
Nx is a build system and dev toolchain for monorepos. It does three big things:
- Project graph — analyzes your code to know which projects depend on which.
- Affected commands — runs lint/test/build only on what changed since main, dramatically speeding CI.
- Computation caching — caches the results of any task locally and (optionally) remotely so you never recompute the same work twice.
Vocabulary you must know
| Term | What it is |
|---|---|
workspace | The Nx monorepo itself, rooted at nx.json |
apps/ | Top-level deployable units (web apps, services). Cannot import from each other. |
libs/ | Shared code. Can be imported by apps and other libs. Where the bulk of your code actually lives. |
project graph | The dependency graph Nx builds by analyzing imports and config |
tags | Labels on projects (e.g. scope:orders, type:ui) |
module boundary rules | ESLint rules using tags to enforce architecture (e.g. scope:orders can't import from scope:inventory) |
affected | Compute the set of projects impacted by changes since a base commit |
generators | Code scaffolders. nx g @nx/react:lib feature-orders |
executors | Task runners. The thing that actually runs your build, test, lint. |
computation cache | Caches task outputs. Local by default, can be remote via Nx Cloud. |
Module Federation | Webpack feature Nx supports for runtime micro-frontend composition |
Common Nx commands
nx build my-app # build a project
nx test my-app # test a project
nx lint my-app
nx affected -t test # run tests on affected projects
nx affected -t build --parallel=3
nx graph # visualize the project graph
nx g @nx/react:app web-frontend # generate an app
nx g @nx/react:lib feature-orders # generate a lib
Library types (a useful Nx convention)
Standard taxonomy you'll see in Nx workspaces:
- feature — smart components for a domain (orders, inventory)
- ui — dumb presentational components
- data-access — API calls, TanStack Query hooks, types
- util — pure utilities, no React
Module-boundary rules typically: feature can use anything, ui can use util only, data-access can use util, util imports nothing.
Likely questions
"Have you used Nx?"
Honest: "I haven't shipped a production Nx app, but I've worked in monorepo setups and the patterns Nx solves — affected builds, module boundaries, computation caching, library isolation — are familiar. I'd ramp on the specifics quickly."
"How would you organize features in our Nx workspace?"
I'd lean on the standard library-type convention — feature, ui, data-access, util — with tags enforcing module boundaries. For an ERP, I'd group by domain: orders, inventory, production, partners. Each gets its own feature lib and its own data-access lib. Cross-cutting UI components live in a shared UI lib. Module-boundary rules prevent orders from reaching into inventory internals — they go through public APIs only.
The "shared kernel" — types, contracts, design tokens — lives in a small util or shared lib that everything can depend on but that depends on nothing.
"Module Federation vs build-time composition?"
Build-time composition (separate libs, imported normally) is simpler and almost always sufficient. Module Federation is for when you genuinely need independent deployments — different teams shipping at different cadences, or wanting to update one app without redeploying the others.
Real costs of Module Federation: shared dependency management is hairy, type sharing across remotes needs extra work, debugging becomes harder, and you take on runtime fetch failures. I'd start with build-time composition and only adopt Module Federation when there's a deployment-independence story that justifies the cost.
02AG Grid — enterprise data grid
What AG Grid is, in one minute
AG Grid is the de facto enterprise React data grid. It's built for scale: tens of thousands of rows with virtualization, complex column definitions, sorting/filtering/grouping, inline editing, and customization at every level. Comes in Community (free, MIT-ish) and Enterprise (paid, with extra features like row grouping, pivoting, master/detail).
Core mental model
Three things you give AG Grid:
- Row data — the array of objects to display
- Column definitions — how to render each column
- Grid options — overall behavior (row models, selection, etc.)
Vocabulary
| Term | What it is |
|---|---|
rowData | The array of row objects |
columnDefs | Array of column configs — field, header, renderer, editor, width, etc. |
defaultColDef | Defaults applied to every column unless overridden |
rowModelType | clientSide (default — all rows in memory) vs serverSide / infinite / viewport (lazy-load chunks) |
cellRenderer | Custom React component to render a cell |
cellEditor | Custom React component for inline editing |
valueGetter / valueFormatter | Compute / format cell value (useful when not 1:1 with field) |
gridApi / columnApi | Imperative API. Refresh cells, get selection, export, scroll. |
quickFilter | Built-in global text filter across all visible columns |
rowGrouping | Enterprise feature. Group by a column. |
masterDetail | Enterprise. Expandable row with a sub-grid. |
aggFunc | Aggregation in grouped/pivoted mode (sum, avg, count) |
Tiny example
import { AgGridReact } from 'ag-grid-react';
const columnDefs = [
{ field: 'sku', headerName: 'SKU', sortable: true, filter: true },
{ field: 'name', headerName: 'Product', flex: 1 },
{ field: 'qty', headerName: 'Qty', editable: true },
{
field: 'status',
headerName: 'Status',
cellRenderer: StatusBadge, // custom React component
},
];
<AgGridReact
rowData={items}
columnDefs={columnDefs}
defaultColDef={{ resizable: true }}
rowSelection="multiple"
pagination
paginationPageSize={50}
/>
The four things to know cold
1. Virtualization (default on)
AG Grid only renders rows visible in the viewport plus a buffer. That's why it can handle 100k rows. The cost: you can't rely on every row's DOM existing — measuring positions, attaching observers per row, etc. needs the grid API.
2. Row models
- Client-side — all rows loaded in memory. Simple, fast, only suitable up to ~50k rows depending on payload.
- Infinite — fetch chunks as you scroll. Good for large datasets where you don't need grouping/pivoting.
- Server-side (Enterprise) — fetch chunks, but supports grouping/pivoting/aggregation server-side. The right choice for very large ERP datasets.
3. Cell renderers and editors
Common pattern: build a small component for any cell that's more than text. Status pills, dropdowns, action buttons. Use React components via the grid's React adapter.
function StatusBadge({ value }) {
const color = value === 'active' ? 'green' : 'gray';
return <span className={`badge bg-${color}-200`}>{value}</span>;
}
4. Grid API for imperative actions
const onGridReady = (params) => {
gridApiRef.current = params.api;
};
// Later:
gridApiRef.current.exportDataAsCsv();
gridApiRef.current.getSelectedRows();
gridApiRef.current.refreshCells({ force: true });
Likely questions
"Have you used AG Grid?"
"I haven't shipped AG Grid specifically, but I've worked with virtualized data grids and the patterns around them — column-based config, custom cell renderers, server-side data sources, performance considerations for large rows. The conceptual model is familiar. I'd come up to speed quickly."
"How would you handle 100k rows in a grid?"
For AG Grid specifically, virtualization handles the rendering side automatically. The remaining concerns are:
- Don't load 100k rows into memory if you don't have to. Use server-side row model — fetch chunks, push grouping and aggregation to the backend.
- Memoize cell renderers. Big lists punish unnecessary re-renders.
- Watch column count too — wide grids with custom renderers can hurt as much as long ones.
- Defer non-critical cell content — formatting heavy values, fetching avatars — until visible.
- Profile in DevTools. Don't optimize without numbers.
"How would you make a column with editable status that updates the backend?"
I'd put a custom cellEditor on the column — likely a select dropdown component. On commit, fire the mutation via TanStack Query with an optimistic update. If it fails, roll back the cache and toast the user. The grid API has events like onCellValueChanged that fire on commit — that's the hook into your mutation flow.
03TanStack Query — server state
What it solves
"Server state" is fundamentally different from client state — async, cached, can become stale, can fail, lives on the server. Treating it like client state (Redux store, useState) means you reinvent caching, deduplication, refetching, and stale handling badly. TanStack Query (formerly React Query) handles all of that.
Mental model
Every server resource has a query key (a serializable array). The library deduplicates fetches with the same key, caches the result, and re-uses it across all components that ask for it.
const { data, isLoading, error } = useQuery({
queryKey: ['orders', { status: 'open' }],
queryFn: () => fetchOrders({ status: 'open' }),
});
Key concepts
| Term | What it is |
|---|---|
queryKey | The cache key. Array of serializable values. |
queryFn | The async function that fetches |
staleTime | How long data is "fresh" — within this, no refetch on mount/focus |
gcTime (formerly cacheTime) | How long unused data stays in cache before being garbage-collected |
useMutation | For writes — creates, updates, deletes |
invalidateQueries | Mark queries stale, triggering refetch |
setQueryData | Imperatively update the cache (for optimistic updates) |
onSuccess / onError / onSettled | Lifecycle callbacks on mutations |
placeholderData | Show cached/old data while a new query loads — great for paginated UIs |
QueryClient | The instance you configure once at the app root |
Mutations + optimistic updates
const queryClient = useQueryClient();
const updateOrder = useMutation({
mutationFn: patchOrder,
onMutate: async (newOrder) => {
// optimistic: cancel in-flight, snapshot, set new
await queryClient.cancelQueries({ queryKey: ['orders'] });
const previous = queryClient.getQueryData(['orders']);
queryClient.setQueryData(['orders'], (old) =>
old.map(o => o.id === newOrder.id ? newOrder : o)
);
return { previous };
},
onError: (err, newOrder, context) => {
queryClient.setQueryData(['orders'], context.previous); // roll back
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['orders'] });
},
});
Likely questions
"What's the difference between staleTime and gcTime?"
staleTime = how long the data is considered fresh. Within staleTime, no automatic refetches on mount or window focus. Default is 0 — meaning always-stale, refetch eagerly.
gcTime = how long unused data lingers in the cache before being garbage-collected. Default is 5 minutes. After it's collected, the next query starts fresh from a loading state.
Tuning: increase staleTime for data that doesn't change often (user profile, config) so you don't waste fetches. Lower it for fast-moving data.
"How do you handle pagination?"
Two patterns:
- Cursor / offset pagination — each page is its own query, with the page number in the queryKey. Use
placeholderData: keepPreviousDatato avoid flicker. - Infinite scroll —
useInfiniteQuerywithgetNextPageParam. Pages stack into a list. Combine withreact-virtualfor performance.
"How do you keep query keys consistent across the app?"
Use a query key factory — a typed object that builds keys for each domain. Single source of truth, prevents typos, makes invalidation precise.
const orderKeys = {
all: ['orders'] as const,
lists: () => [...orderKeys.all, 'list'] as const,
list: (filters: Filters) => [...orderKeys.lists(), filters] as const,
details: () => [...orderKeys.all, 'detail'] as const,
detail: (id: string) => [...orderKeys.details(), id] as const,
};
Now invalidateQueries({ queryKey: orderKeys.lists() }) invalidates every list query without touching detail queries.
"How do you handle errors and retries?"
By default TanStack Query retries 3 times with exponential backoff. Configure per-query or globally on the QueryClient. For specific status codes you don't want to retry (404, 401), customize the retry function:
retry: (failureCount, error) => {
if (error.status === 404) return false;
return failureCount < 3;
}
Surface errors via the error field on the hook, plus a global error handler on the QueryClient for monitoring (Sentry / App Insights).
04Base UI — headless component primitives
The JD mentions "Base UI (where appropriate)". Base UI is the unstyled, accessible primitives library from the MUI team (it grew out of MUI Base / MUI's "headless" layer). Think of it as a competitor to Radix UI or Headless UI: it ships behavior + accessibility + keyboard handling for things like Dialog, Popover, Select, Tabs, Menu, Slider, Tooltip — but no styles. You bring the styles (Tailwind, CSS modules, whatever).
Why teams pick it
- Accessibility done right out of the box — focus trap, ARIA, keyboard nav.
- Total visual freedom — Tailwind users love this because there's nothing to override.
- Smaller surface area than full MUI — you ship only what you use.
- Composable, low-magic API — primitives are
{Root, Trigger, Popup, ...}patterns you assemble.
Tiny example (Dialog)
import { Dialog } from '@base-ui-components/react/dialog';
<Dialog.Root>
<Dialog.Trigger className="btn">Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop className="fixed inset-0 bg-black/40" />
<Dialog.Popup className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white p-6 rounded-lg">
<Dialog.Title>Confirm</Dialog.Title>
<Dialog.Description>Are you sure?</Dialog.Description>
<Dialog.Close className="btn">Cancel</Dialog.Close>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
Likely questions
"Why would you reach for Base UI over building your own?"
Accessibility is the unsexy work that's easy to get wrong — focus management in a dialog, roving tabindex in a menu, ARIA roles in a tab list. Base UI ships that correctly. Building it yourself is weeks of edge cases and screen-reader testing. Reaching for a primitive library is the leverage move; you keep your design freedom because nothing's pre-styled.
"Base UI vs. Radix vs. Headless UI?"
All three solve the same problem — accessible, unstyled primitives. Differences are mostly API style and ecosystem fit. Base UI sits closer to MUI's design philosophy and has a slightly more compositional API. Radix is the most mature and has the largest community. Headless UI is the smallest surface area and ships from the Tailwind team. For a team already on the MUI side of the world, Base UI is the natural choice.
05Routing — likely TanStack Router
The JD says "Modern routing solution" without naming one. Given they're already TanStack-aligned for Query, the most likely answer is TanStack Router. Worth knowing the basics either way; you can ask in stage 3.
Why TanStack Router gets picked
- Type-safe routes, params, and search params — end-to-end TS inference.
useParams()anduseSearch()return typed objects, notRecord<string, string>. - First-class search-param state — search params are validated, typed, and treated as proper state (not just URL noise). You can use
useSearch()like you'd useuseState. - Route loaders — fetch data before a route renders, with built-in integration with TanStack Query (
queryClient.ensureQueryDatain a loader). - File-based or code-based routing — pick your preference.
- Pending / error / not-found states are a first-class part of the route definition.
Mental shift from React Router
If you've used React Router v6/v7, the conceptual jump is small — nested routes, outlets, loaders, actions all map. The two big differences: TanStack Router is more aggressive about types (you'll feel it on every Link and navigate call), and search-params-as-state is a much bigger deal than React Router treats it.
Likely questions
"What router are you familiar with?"
"I've used React Router across several projects. I haven't shipped TanStack Router in production but I'm familiar with the model — the type-safe params, the loader pattern, the search-param-as-state idea. If you're using it I'd ramp quickly because the conceptual model is the same; the win is the type safety. Are you on TanStack Router, or did you go a different direction?"
(Then: actually ask. The JD is vague on purpose or by accident; either way, it's a legitimate clarifier.)
"How would TanStack Router and TanStack Query work together?"
The clean pattern: each route's loader calls queryClient.ensureQueryData(...) with the same query key the component will later use via useQuery. The loader guarantees the data is in cache before the route renders, so the component never shows a loading state on first paint. After mount, useQuery takes over for refetches and invalidations. You get instant navigation + proper caching + a single source of truth for what data the route needs.
06How they fit together at Omnesoft
Likely architecture you'll be working in:
- Nx workspace — apps/ for the deployable web frontends, libs/ for features and shared code, with module-boundary rules enforcing architecture.
- TanStack Query in
libs/data-access-*— domain-specific hooks that wrap the .NET / FastEndpoints API, with typed responses generated from OpenAPI. - AG Grid as the workhorse for inventory, orders, partners, scheduling — the core ERP UIs.
- React + TypeScript + Tailwind as the surface layer — design system in a
libs/uilib, likely composing Base UI primitives for accessibility-heavy bits (dialogs, popovers, menus). - A modern router — most likely TanStack Router given the rest of the stack — wiring route loaders into TanStack Query for instant navigation.
- Playwright for E2E flows. Jest + RTL for unit/integration. GitHub Actions running affected builds with Nx Cloud caching.
When asked, describe it back to them like this. They'll feel understood.