TanStack Query Component Binding Analysis
How should design system components (@arda-cards/design-system) bind to
server data managed by TanStack Query? This analysis explores the architectural
options for connecting reusable UI components to their data sources across deep
component hierarchies.
1. The Problem
Section titled “1. The Problem”Consider a reusable AddressCard component that renders a postal address. It
is used in multiple contexts:
- Supplier detail view — renders the supplier’s company address
- Order detail view — renders the delivery address
- Contact detail view — renders the contact’s personal address
- Business Affiliate list — renders in an AG Grid cell
Each context fetches the address data from a different query (different
endpoint, different query key, different parent entity). The AddressCard
needs the same data shape (PostalAddress) but the source varies.
The question: who is responsible for fetching the data and how is the component connected to it?
Three options are evaluated. Option B (pure props, app owns all fetching) is the current pattern used by the image upload components and is included as a baseline but not expanded — it works for flat structures but doesn’t scale to deep nesting. Options A and C are the primary candidates.
2. Option A: Components Declare Their Own Queries
Section titled “2. Option A: Components Declare Their Own Queries”Components import useQuery/useMutation directly. Each component knows
what data it needs and fetches it using a query key passed as a prop.
2.1 Basic pattern
Section titled “2.1 Basic pattern”// In @arda-cards/design-system — component owns data fetchingimport { useQuery } from '@tanstack/react-query';
interface AddressCardProps { /** TanStack query key that resolves to a PostalAddress. */ queryKey: readonly unknown[]; /** Query function — fetches the PostalAddress from the appropriate source. */ queryFn: () => Promise<PostalAddress>;}
function AddressCard({ queryKey, queryFn }: AddressCardProps) { const { data, isLoading, error } = useQuery({ queryKey, queryFn });
if (isLoading) return <AddressSkeleton />; if (error) return <AddressError error={error} />; return ( <div> <p>{data.street1}</p> {data.street2 && <p>{data.street2}</p>} <p>{data.city}, {data.state} {data.postalCode}</p> <p>{data.country}</p> </div> );}2.2 Reuse across contexts
Section titled “2.2 Reuse across contexts”// Supplier detail — address comes from business affiliate query<AddressCard queryKey={['business-affiliate', supplierId, 'address']} queryFn={() => getBusinessAffiliate(supplierId).then(ba => ba.companyInfo.postalAddress)}/>
// Order detail — address comes from order query<AddressCard queryKey={['order', orderId, 'delivery-address']} queryFn={() => getOrder(orderId).then(o => o.deliveryAddress)}/>
// Contact detail — address comes from contact sub-resource<AddressCard queryKey={['contact', contactId, 'address']} queryFn={() => getContact(contactId).then(c => c.address)}/>2.3 Deep nesting example
Section titled “2.3 Deep nesting example”OrderPage├── OrderHeader ← useQuery(['order', id])├── SupplierPanel ← useQuery(['ba', suppId])│ ├── CompanyInfoSection│ │ └── AddressCard ← useQuery(['ba', suppId, 'address'], select from parent)│ ├── ContactList ← useQuery(['ba', suppId, 'contacts'])│ │ └── ContactCard[]│ │ └── AddressCard ← useQuery(['contact', cId, 'address'])│ └── RolesList ← useQuery(['ba', suppId, 'roles'])├── OrderLinesGrid ← useQuery(['order', id, 'lines'])└── DeliverySection └── AddressCard ← useQuery(['order', id, 'delivery-address'])Each AddressCard declares its own query. No prop drilling through
intermediate components. The parent only needs to pass the query key and
function — not the data itself.
2.4 Shared cache optimization
Section titled “2.4 Shared cache optimization”When the parent already has the data (e.g., SupplierPanel fetched the full
business affiliate), the child can use select to derive its slice without
an additional network request:
// SupplierPanel already fetched ['ba', suppId]// AddressCard can share that cache entry<AddressCard queryKey={['ba', supplierId]} queryFn={() => getBusinessAffiliate(supplierId)} select={(ba) => ba.companyInfo.postalAddress}/>But this requires the component to accept a generic queryFn that returns
a broader type plus a select function to narrow it — which complicates
the type signature significantly:
interface AddressCardProps<TData = PostalAddress> { queryKey: readonly unknown[]; queryFn: () => Promise<TData>; select?: (data: TData) => PostalAddress;}2.5 The type safety problem
Section titled “2.5 The type safety problem”Query keys are opaque. The type system cannot verify that a query key resolves to the expected data shape. The following compiles without error:
// BUG: this query returns an Order, not a PostalAddress<AddressCard queryKey={['order', orderId]} queryFn={() => getOrder(orderId)} // Returns Order, not PostalAddress/>TypeScript catches this only if queryFn is typed to return
Promise<PostalAddress>. But with select:
// queryFn returns Order, select narrows to PostalAddress — correct<AddressCard queryKey={['order', orderId]} queryFn={() => getOrder(orderId)} select={(order) => order.deliveryAddress}/>
// BUG: select returns the wrong field — still compiles if field is also PostalAddress-shaped<AddressCard queryKey={['order', orderId]} queryFn={() => getOrder(orderId)} select={(order) => order.billingAddress} // Wrong address, but same type/>The select function provides structural type safety (the shape must
match) but not semantic safety (it could be the wrong address).
Query keys provide no type information at all. If a component accepts
only queryKey (relying on the cache to already be populated by a parent),
the type system has zero visibility into what the key resolves to:
// This is essentially 'any' — no compile-time safetyinterface AddressCardProps { queryKey: readonly unknown[];}TanStack Query does offer query key type registration via module augmentation, but this is a global registry pattern that requires careful coordination and doesn’t naturally compose with per-instance key variation.
2.6 Mutation binding
Section titled “2.6 Mutation binding”For editable components, the same pattern extends to mutations:
interface EditableAddressCardProps<TData = PostalAddress> { queryKey: readonly unknown[]; queryFn: () => Promise<TData>; select?: (data: TData) => PostalAddress; onSave: (address: PostalAddress) => Promise<void>; invalidateKeys?: readonly unknown[][]; // Keys to invalidate after save}The invalidateKeys prop lets the parent specify which queries to refresh
after a mutation. This is powerful but adds configuration surface and moves
cache invalidation logic from a centralized place to prop-passing throughout
the tree.
3. Option C: Typed Data Provider Strategy
Section titled “3. Option C: Typed Data Provider Strategy”Components define a typed contract (interface) for their data needs. The consuming app provides an implementation backed by TanStack Query (or any other data source). Components never import TanStack Query directly.
3.1 Basic pattern
Section titled “3.1 Basic pattern”// In @arda-cards/design-system — component defines its data contract
/** What AddressCard needs from its environment. */interface AddressDataHook { (id: string): { data: PostalAddress | undefined; isLoading: boolean; error: Error | null; };}
interface AddressCardProps { /** The address identifier (could be entity ID, composite key, etc.). */ addressId: string; /** Data hook provided by the consuming application. */ useAddressData: AddressDataHook;}
function AddressCard({ addressId, useAddressData }: AddressCardProps) { const { data, isLoading, error } = useAddressData(addressId);
if (isLoading) return <AddressSkeleton />; if (error) return <AddressError error={error} />; return ( <div> <p>{data.street1}</p> {data.street2 && <p>{data.street2}</p>} <p>{data.city}, {data.state} {data.postalCode}</p> <p>{data.country}</p> </div> );}3.2 App provides TanStack implementation
Section titled “3.2 App provides TanStack implementation”// In arda-frontend-app — TanStack-backed implementations
function useSupplierAddress(supplierId: string) { const query = useQuery({ queryKey: ['business-affiliate', supplierId], queryFn: () => getBusinessAffiliate(supplierId), select: (ba) => ba.companyInfo.postalAddress, }); return { data: query.data, isLoading: query.isLoading, error: query.error };}
function useOrderDeliveryAddress(orderId: string) { const query = useQuery({ queryKey: ['order', orderId], queryFn: () => getOrder(orderId), select: (order) => order.deliveryAddress, }); return { data: query.data, isLoading: query.isLoading, error: query.error };}
function useContactAddress(contactId: string) { const query = useQuery({ queryKey: ['contact', contactId], queryFn: () => getContact(contactId), select: (c) => c.address, }); return { data: query.data, isLoading: query.isLoading, error: query.error };}3.3 Reuse across contexts
Section titled “3.3 Reuse across contexts”// Supplier detail<AddressCard addressId={supplierId} useAddressData={useSupplierAddress} />
// Order detail<AddressCard addressId={orderId} useAddressData={useOrderDeliveryAddress} />
// Contact detail<AddressCard addressId={contactId} useAddressData={useContactAddress} />3.4 Deep nesting with Context to reduce prop drilling
Section titled “3.4 Deep nesting with Context to reduce prop drilling”For deep hierarchies, a React Context can provide the data hooks so they don’t need to be passed through every level:
// In @arda-cards/design-system — context contract
interface OrderDataProviders { useOrderData: (orderId: string) => { data: Order | undefined; isLoading: boolean; error: Error | null }; useSupplierData: (supplierId: string) => { data: BusinessAffiliate | undefined; isLoading: boolean; error: Error | null }; useAddressData: (addressId: string) => { data: PostalAddress | undefined; isLoading: boolean; error: Error | null }; useContactsData: (supplierId: string) => { data: Contact[] | undefined; isLoading: boolean; error: Error | null };}
const OrderDataContext = createContext<OrderDataProviders | null>(null);
function useOrderProviders() { const ctx = useContext(OrderDataContext); if (!ctx) throw new Error('OrderDataContext not provided'); return ctx;}// Deeply nested component — no prop drillingfunction ContactAddressCard({ contactId }: { contactId: string }) { const { useAddressData } = useOrderProviders(); const { data, isLoading } = useAddressData(contactId); // renders...}// In arda-frontend-app — provide TanStack implementations at page levelfunction OrderPage({ orderId }: { orderId: string }) { const providers: OrderDataProviders = useMemo(() => ({ useOrderData: (id) => useOrderQuery(id), useSupplierData: (id) => useSupplierQuery(id), useAddressData: (id) => useAddressQuery(id), useContactsData: (id) => useContactsQuery(id), }), []);
return ( <OrderDataContext.Provider value={providers}> <OrderDetail orderId={orderId} /> </OrderDataContext.Provider> );}3.5 Type safety analysis
Section titled “3.5 Type safety analysis”The contract is fully typed at the interface boundary:
// This is a compile error — useSupplierAddress returns PostalAddress,// but the component expects a hook returning Order<OrderHeader useOrderData={useSupplierAddress} />// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^// Type '(id: string) => { data: PostalAddress | undefined; ... }'// is not assignable to type '(id: string) => { data: Order | undefined; ... }'Every assembly error is caught by tsc:
| Error type | Option A (query key) | Option C (typed provider) |
|---|---|---|
| Wrong data shape | Caught only if queryFn return type is specific | Caught — hook return type must match |
| Wrong entity ID type | Not caught (key is unknown[]) | Caught — parameter type enforced |
| Missing data source | Runtime error (no queryFn) | Caught — required prop/context |
| Wrong select function | Structural match only (same shape, wrong semantics) | Caught — each context has its own hook |
| Wrong cache key | Not caught at all | N/A — no cache keys in component |
3.6 Mutation binding
Section titled “3.6 Mutation binding”// In @arda-cards/design-system — mutation contractinterface EditableAddressCardProps { addressId: string; useAddressData: (id: string) => { data: PostalAddress | undefined; isLoading: boolean; error: Error | null; }; useUpdateAddress: () => { mutate: (address: PostalAddress) => void; isPending: boolean; error: Error | null; };}// In arda-frontend-app — TanStack-backed mutationfunction useUpdateSupplierAddress() { const queryClient = useQueryClient(); const mutation = useMutation({ mutationFn: (address: PostalAddress) => api.updateBusinessAffiliate(supplierId, { companyInfo: { postalAddress: address } }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['business-affiliate', supplierId] }); }, }); return { mutate: mutation.mutate, isPending: mutation.isPending, error: mutation.error };}Cache invalidation is managed in the app, not in the component. The component only knows “I can save an address” — it doesn’t know which cache keys to invalidate.
3.7 Storybook compatibility
Section titled “3.7 Storybook compatibility”Because components don’t import TanStack Query, Storybook stories can provide
simple mock implementations without QueryClientProvider:
// In ux-prototype story — no TanStack dependencyconst mockUseAddress = (id: string) => ({ data: { street1: '123 Main St', city: 'Springfield', state: 'IL', postalCode: '62701', country: 'US' }, isLoading: false, error: null,});
export const Default: Story = { args: { addressId: 'addr-1', useAddressData: mockUseAddress, },};4. Option A vs. Option C: Detailed Comparison
Section titled “4. Option A vs. Option C: Detailed Comparison”4.1 Type safety
Section titled “4.1 Type safety”| Aspect | Option A (TanStack-aware) | Option C (Typed provider) |
|---|---|---|
| Data shape validation | Partial — queryFn return type checked, but select can mask errors; queryKey is untyped | Full — hook return type must match expected data type at every call site |
| Entity ID validation | None — query keys are unknown[] | Full — hook parameter is typed (string, composite type, etc.) |
| Assembly-time errors | Some caught by tsc, some deferred to runtime (wrong key, missing cache) | All caught by tsc — missing or mistyped provider is a compile error |
| Refactoring safety | Low — renaming a query key is a string change across call sites | High — renaming a hook or changing its signature triggers tsc errors at every consumer |
4.2 Coupling and portability
Section titled “4.2 Coupling and portability”| Aspect | Option A | Option C |
|---|---|---|
| TanStack dependency | Required in design system (peerDependency) | None in design system |
QueryClientProvider | Required everywhere components are used | Not required — Storybook, tests, other apps work without it |
| Library swap | If TanStack Query is replaced, every component changes | If TanStack Query is replaced, only the app’s hook implementations change |
| Multi-app reuse | All apps must use TanStack Query | Any app can provide hooks backed by any data source |
4.3 Developer experience
Section titled “4.3 Developer experience”| Aspect | Option A | Option C |
|---|---|---|
| Boilerplate per component | Low — useQuery({ queryKey, queryFn }) inline | Medium — define interface, accept hook prop, call hook |
| Boilerplate per usage site | Medium — construct query key + queryFn per instance | Medium — pass the appropriate hook implementation |
| Discoverability | High — component source shows exactly what it fetches | High — interface shows exactly what data shape is needed |
| IDE support | Good — TanStack Query has TypeScript plugin | Good — standard TypeScript interfaces |
| Debugging | TanStack DevTools shows all queries in one panel | TanStack DevTools still works (queries are in the app), but component code doesn’t show in the DevTools |
4.4 Maintenance
Section titled “4.4 Maintenance”| Aspect | Option A | Option C |
|---|---|---|
| Adding a new data source | Add new queryKey/queryFn at call site — component unchanged | Implement new hook in app — component unchanged |
| Changing data shape | Update component + all queryFn/select at call sites | Update component interface + all hook implementations |
| Cache strategy changes | Spread across component props (staleTime, gcTime, etc.) | Centralized in app’s hook implementations |
| Design system versioning | Breaking change if TanStack Query version changes | Not affected by TanStack Query version |
| Testing components | Requires QueryClientProvider wrapper in every test | Mock hooks are plain functions — no wrapper needed |
4.5 Performance
Section titled “4.5 Performance”| Aspect | Option A | Option C |
|---|---|---|
| Render optimization | TanStack’s select + structural sharing built-in | Same — hooks in app use select internally |
| Fetch deduplication | Automatic if same queryKey used across components | Automatic — same underlying TanStack queries in app |
| Bundle size (design system) | Includes TanStack Query types in bundle | No TanStack types in bundle |
4.6 Composition in deep hierarchies
Section titled “4.6 Composition in deep hierarchies”| Aspect | Option A | Option C |
|---|---|---|
| Prop drilling | Avoided — each component fetches its own data | Avoided via Context providers |
| Parent-child coordination | Implicit via shared cache (same queryKey) | Explicit via provider contract |
| New nested component | Just add useQuery with appropriate key | Add hook to context interface, implement in app |
| Orphan queries | Possible — component may fetch data no one invalidates | Not possible — every data source is explicitly wired |
5. Concrete Example: AddressCard in Four Contexts
Section titled “5. Concrete Example: AddressCard in Four Contexts”The data types
Section titled “The data types”interface PostalAddress { street1: string; street2?: string | null; city: string; state: string; postalCode: string; country: string;}Option A usage
Section titled “Option A usage”// Context 1: Supplier company address<AddressCard queryKey={['ba', supplierId, 'company-address']} queryFn={() => getBA(supplierId).then(ba => ba.companyInfo.postalAddress)}/>
// Context 2: Order delivery address<AddressCard queryKey={['order', orderId, 'delivery']} queryFn={() => getOrder(orderId).then(o => o.deliveryAddress)}/>
// Context 3: Order billing address (SAME component, different query)<AddressCard queryKey={['order', orderId, 'billing']} queryFn={() => getOrder(orderId).then(o => o.billingAddress)}/>
// Context 4: Contact address (inside a list){contacts.map(c => ( <AddressCard key={c.eId} queryKey={['contact', c.eId, 'address']} queryFn={() => getContact(c.eId).then(ct => ct.address)} />))}Type safety gap: If getOrder(orderId) changes its return type and
deliveryAddress is renamed to shippingAddress, the select function
fails at runtime (property access on undefined), not at compile time — unless
the queryFn return type is explicitly annotated.
Option C usage
Section titled “Option C usage”// App defines context-specific hooksfunction useSupplierCompanyAddress(supplierId: string) { return useQuery({ queryKey: ['ba', supplierId], queryFn: () => getBA(supplierId), select: (ba) => ba.companyInfo.postalAddress, // ^^^ tsc error if PostalAddress shape changes });}
function useOrderDeliveryAddress(orderId: string) { return useQuery({ queryKey: ['order', orderId], queryFn: () => getOrder(orderId), select: (o) => o.deliveryAddress, // ^^^ tsc error if field renamed to shippingAddress });}
function useOrderBillingAddress(orderId: string) { ... }function useContactAddress(contactId: string) { ... }
// Component usage — fully typed, tsc-verified<AddressCard addressId={supplierId} useAddressData={useSupplierCompanyAddress} /><AddressCard addressId={orderId} useAddressData={useOrderDeliveryAddress} /><AddressCard addressId={orderId} useAddressData={useOrderBillingAddress} />{contacts.map(c => ( <AddressCard key={c.eId} addressId={c.eId} useAddressData={useContactAddress} />))}Type safety: If getOrder() return type changes, tsc catches it
in useOrderDeliveryAddress (the select function). If AddressCard
changes its expected data type, tsc catches it at every usage site
where the hook return type no longer matches.
6. AG Grid: Applying FD-01 to Table Components
Section titled “6. AG Grid: Applying FD-01 to Table Components”AG Grid cell renderers and editors are instantiated by the grid framework via column definitions, not via JSX composition. This creates a unique challenge for the typed provider pattern (Option C / FD-01): props cannot be passed through JSX children. This section covers how the decision translates to table-based design system components.
The grid wrapper (ArdaEntityDataGrid in ux-prototype, evolving from
ArdaGrid currently in arda-frontend-app) and its cell renderers and
editors are design system components. Under FD-01, they must not import
@tanstack/react-query directly.
6.1 Cell renderers (read-only) — no provider needed
Section titled “6.1 Cell renderers (read-only) — no provider needed”Cell renderers receive data from AG Grid’s row model via value and data
props. They are pure rendering functions with no data-fetching concern:
// Design system — cell renderer is a pure function of its propsinterface ImageCellDisplayProps { value: string | null; // imageUrl from row data data: { name?: string }; // row record for initials fallback config: ImageFieldConfig;}
function ImageCellDisplay({ value, data, config }: ImageCellDisplayProps) { return <ImageDisplay imageUrl={value} entityName={data.name} config={config} />;}The data is already in the grid. The app is responsible for populating
rowData — in practice via a TanStack Query hook at the page level:
// In arda-frontend-app — app owns the datafunction ItemsPage() { const { data: items, isLoading } = useQuery({ queryKey: ['items', page], queryFn: () => fetchItems(page), });
return <ArdaEntityDataGrid rowData={items ?? []} loading={isLoading} ... />;}6.2 Cell editors — three categories
Section titled “6.2 Cell editors — three categories”Category 1: Value-only editors (no server interaction)
Section titled “Category 1: Value-only editors (no server interaction)”Editors that modify a cell value locally and return it to the grid. AG Grid
manages the lifecycle: the editor calls stopEditing(), the grid reads
getValue(), and the app persists the change via onCellValueChanged.
Examples: text input, number input, select dropdown (with static options), checkbox, color picker.
No typed provider needed — these editors are self-contained.
Category 2: Editors with server-side lookups (typeahead)
Section titled “Category 2: Editors with server-side lookups (typeahead)”Editors that need to fetch suggestions from the server while the user types.
The current codebase has 9 of these (SupplierCellEditor,
TypeCellEditor, FacilityCellEditor, etc.), all calling
ardaClient.lookupXxx() directly.
Under FD-01, the design system defines a typed lookup hook contract:
// In @arda-cards/design-system — the contract
/** Hook contract for typeahead lookup data. */interface LookupHook { (query: string): { data: string[] | undefined; isLoading: boolean; };}
/** Props for a typeahead cell editor. */interface TypeaheadCellEditorConfig { /** Typed lookup hook — provided by the consuming app. */ useLookup: LookupHook; /** Placeholder text for the search input. */ placeholder?: string; /** Whether to allow free-text entry (not just lookup results). */ allowFreeText?: boolean;}The app provides TanStack-backed implementations:
// In arda-frontend-app — TanStack-backed lookup hooks
function useSupplierLookup(query: string) { const q = useQuery({ queryKey: ['lookups', 'suppliers', query], queryFn: () => api.lookupSuppliers(query), enabled: query.length >= 2, staleTime: 5 * 60 * 1000, }); return { data: q.data, isLoading: q.isLoading };}
function useFacilityLookup(query: string) { const q = useQuery({ queryKey: ['lookups', 'facilities', query], queryFn: () => api.lookupFacilities(query), enabled: query.length >= 2, staleTime: 5 * 60 * 1000, }); return { data: q.data, isLoading: q.isLoading };}Category 3: Editors with server-side mutations
Section titled “Category 3: Editors with server-side mutations”Editors that perform server interactions during the editing session.
ImageCellEditor is the current example — it opens a dialog that uploads
an image via onUpload. Future examples: an inline address editor that
validates addresses against an external service, or an order line editor
that checks inventory availability.
Under FD-01, these accept typed mutation hooks:
// In @arda-cards/design-system — the contract
interface ImageUploadHook { (): { mutateAsync: (file: Blob) => Promise<string>; isPending: boolean; error: Error | null; };}
interface ImageCellEditorConfig { imageConfig: ImageFieldConfig; useImageUpload: ImageUploadHook; useCheckReachability: ReachabilityHook;}6.3 Wiring providers to cell editors
Section titled “6.3 Wiring providers to cell editors”AG Grid instantiates cell editors from column definitions. The component
specified in cellEditor is constructed by the grid, not rendered via JSX.
There are two patterns for injecting typed providers.
Pattern A: Factory functions (explicit, per-column)
Section titled “Pattern A: Factory functions (explicit, per-column)”The design system exports a factory that closes over the typed hooks and returns an AG Grid–compatible cell editor component:
// In @arda-cards/design-system — factory
export function createTypeaheadCellEditor(config: TypeaheadCellEditorConfig) { return forwardRef<ICellEditorComp, ICellEditorParams>((props, ref) => { return <TypeaheadCellEditor {...props} {...config} ref={ref} />; });}
export function createImageCellEditor(config: ImageCellEditorConfig) { return forwardRef<ICellEditorComp, ICellEditorParams>((props, ref) => { return <ImageCellEditor {...props} {...config} ref={ref} />; });}The app wires providers in column definitions:
// In arda-frontend-app — column definitions
const columnDefs: ColDef[] = [ { field: 'imageUrl', cellRenderer: ImageCellDisplay, cellEditor: createImageCellEditor({ imageConfig: ITEM_IMAGE_CONFIG, useImageUpload: useItemImageUpload, // TanStack-backed useCheckReachability: useCheckReachability, // TanStack-backed }), editable: true, }, { field: 'primarySupply.supplier', cellEditor: createTypeaheadCellEditor({ useLookup: useSupplierLookup, // TanStack-backed placeholder: 'Search suppliers...', }), editable: true, }, { field: 'locator.facility', cellEditor: createTypeaheadCellEditor({ useLookup: useFacilityLookup, // Different hook, same component placeholder: 'Search facilities...', }), editable: true, }, { field: 'locator.department', cellEditor: createTypeaheadCellEditor({ useLookup: useDepartmentLookup, // Yet another hook, same component placeholder: 'Search departments...', }), editable: true, },];Type safety: If useFacilityLookup returns { data: Facility[] } instead
of { data: string[] }, tsc catches it at the createTypeaheadCellEditor
call site — the hook doesn’t satisfy LookupHook.
Reuse: The same createTypeaheadCellEditor factory is used for all 9+
typeahead columns. Only the useLookup hook varies. The component is written
once in the design system; the app provides context-specific data sources.
Pattern B: React Context (implicit, per-grid)
Section titled “Pattern B: React Context (implicit, per-grid)”When many editors in the same grid share a common set of providers, a context at the grid level eliminates the need to pass hooks through every factory:
// In @arda-cards/design-system — context contract
interface GridEditorProviders { lookups: Record<string, LookupHook>; useImageUpload?: ImageUploadHook; useCheckReachability?: ReachabilityHook;}
const GridEditorContext = createContext<GridEditorProviders | null>(null);
export function useGridEditorProviders() { const ctx = useContext(GridEditorContext); if (!ctx) throw new Error('GridEditorContext not provided'); return ctx;}// In arda-frontend-app — provide at page level
const providers: GridEditorProviders = { lookups: { 'primarySupply.supplier': useSupplierLookup, 'locator.facility': useFacilityLookup, 'locator.department': useDepartmentLookup, 'classification.type': useTypeLookup, // ... }, useImageUpload: useItemImageUpload, useCheckReachability: useCheckReachability,};
<GridEditorContext.Provider value={providers}> <ArdaEntityDataGrid rowData={items} columnDefs={columnDefs} /></GridEditorContext.Provider>// In @arda-cards/design-system — cell editor reads from context
function TypeaheadCellEditor({ column, ...agGridProps }: ICellEditorParams) { const { lookups } = useGridEditorProviders(); const fieldName = column.getColDef().field; const useLookup = lookups[fieldName]; // ... use the hook}Trade-off vs. factory pattern:
| Aspect | Factory (Pattern A) | Context (Pattern B) |
|---|---|---|
| Type safety per column | Strong — each factory call is type-checked | Weaker — Record<string, LookupHook> loses per-field typing; wrong field name is a runtime error |
| Boilerplate | One createXxx() call per column | One context provider per grid |
| Discoverability | Explicit — column def shows exactly which hook | Implicit — must read context to know what’s available |
| Adding a new lookup column | Add factory call in column def | Add key to lookups record |
| Mismatched hook | tsc error at column def | Runtime error (key not found) |
Recommended approach: Factory as primary, Context as complement
Section titled “Recommended approach: Factory as primary, Context as complement”Given FD-01’s emphasis on tsc-time safety:
-
Factory pattern (Pattern A) is the primary mechanism for cell editors that need server interaction. Each column definition explicitly wires the correct typed hook, and mismatches are compile errors.
-
Context pattern (Pattern B) is a complement for grids with many editors sharing an identical provider set (e.g., all typeahead editors use
LookupHookwith the same shape). The context can provide a fallback — if a factory doesn’t inject a specific hook, the editor reads from context. But the context is opt-in, not the primary mechanism.
In practice, the column definition is the natural assembly point for grid
configuration in AG Grid. It already specifies cellEditor, cellRenderer,
valueSetter, and other per-column concerns. Adding the data provider to
the column definition via a factory is consistent with this pattern.
6.4 Grid data source (rowData vs. server-side row model)
Section titled “6.4 Grid data source (rowData vs. server-side row model)”The grid wrapper itself (ArdaEntityDataGrid) is a design system component.
Under FD-01, it accepts data via props:
// Design system — grid wrapper accepts data, doesn't fetch itinterface ArdaEntityDataGridProps<T> { rowData: T[]; loading?: boolean; error?: string | null; columnDefs: ColDef<T>[]; // ... other grid configuration}For the client-side row model, the app provides rowData from a TanStack
query. The grid is a pure renderer.
For future server-side row model (SSRM) scenarios, the grid needs a
getRows callback that fetches pages on demand. Under FD-01, this callback
would be a typed provider:
// Design system — typed data source contract for SSRMinterface ServerRowModelDataSource<T> { getRows: (params: { startRow: number; endRow: number; sortModel: SortModel[]; filterModel: FilterModel; }) => Promise<{ rows: T[]; totalCount: number }>;}
interface ArdaEntityDataGridProps<T> { // Client-side mode rowData?: T[]; // Server-side mode (mutually exclusive with rowData) serverDataSource?: ServerRowModelDataSource<T>; // ... common props}The app provides a TanStack-backed implementation:
// In arda-frontend-appconst itemsDataSource: ServerRowModelDataSource<Item> = { getRows: async (params) => { const result = await queryClient.fetchQuery({ queryKey: ['items', 'grid', params], queryFn: () => api.queryItems(params), staleTime: 60_000, }); return { rows: result.items, totalCount: result.totalCount }; },};
<ArdaEntityDataGrid serverDataSource={itemsDataSource} ... />This keeps TanStack Query in the app while the design system defines the contract shape.
7. Assessment and Decision
Section titled “7. Assessment and Decision”Decision FD-01 (recorded in decision-log.md): Option C (mandatory typed providers) for all design system components in
ux-prototype. Option A (inlineuseQuery/useMutation) acceptable for app-specific local components inarda-frontend-app. For AG Grid cell editors, the factory pattern (section 6.3, Pattern A) is the primary mechanism; React Context (Pattern B) is a complement for grids with many editors sharing identical provider shapes.
Decision factors
Section titled “Decision factors”| Factor | Favors Option A | Favors Option C |
|---|---|---|
| Type safety at assembly time | Strong — all errors at tsc | |
| Design system portability | Strong — no TanStack coupling | |
| Storybook/testing simplicity | Strong — no QueryClientProvider needed | |
| Less boilerplate per component | Moderate — inline useQuery | |
| Fewer files to maintain | Moderate — no separate hook layer | |
| TanStack DevTools visibility | Minor — queries visible in component code | |
| Cache strategy centralization | Strong — staleTime/gcTime in app, not component | |
| Refactoring safety | Strong — rename propagates via types | |
| Library swap resilience | Strong — only app hooks change |
Recommendation
Section titled “Recommendation”Option C (Typed Data Provider Strategy) is recommended for the design
system (ux-prototype). The primary reasons:
-
Type safety is non-negotiable for a shared component library. Errors must be caught at
tsctime, not at runtime. Option A’s reliance onunknown[]query keys and untyped select functions creates a class of errors that bypass the compiler. -
The design system serves multiple consumers. It must remain portable and backend-agnostic. Coupling to TanStack Query limits reuse and creates a version-coordination burden.
-
Cache strategy is an app concern, not a component concern. Components should declare what data they need, not how to fetch it or how long to cache it.
-
The cost is manageable. The additional hook layer in
arda-frontend-appis mechanical — each hook is 5-10 lines. The Context-based provider pattern avoids prop drilling in deep hierarchies.
Option A may be appropriate for app-specific components in
arda-frontend-app that are not part of the design system and will never
be reused outside that app. For those, the reduced boilerplate of inline
useQuery is a pragmatic choice.
Application to AG Grid
Section titled “Application to AG Grid”Section 6 details how FD-01 translates to table-based components:
- Cell renderers — no provider needed; they receive data from AG Grid’s
row model via
value/dataprops. - Cell editors with server interaction (typeahead lookups, image upload) — use factory functions that close over typed hooks. Each column definition explicitly wires the correct provider, and mismatches are compile errors.
- Grid data source — the grid wrapper accepts
rowDataas a prop (app provides via TanStack Query). Future SSRM uses a typedServerRowModelDataSourcecontract.
Boundary summary
Section titled “Boundary summary”| Component location | Binding approach | Data fetching |
|---|---|---|
Design system (ux-prototype) — all components | Option C: typed providers | Never imports TanStack; accepts typed hooks via props/context/factory |
| Design system — AG Grid cell editors | Option C via factory functions | Factory closes over typed hooks; column def is the assembly point |
App-specific pages (arda-frontend-app) | Option A: inline TanStack | Direct useQuery/useMutation — not reused outside the app |
App-specific wiring hooks (arda-frontend-app) | TanStack implementations of design system contracts | Implements typed hook interfaces with TanStack Query |
Copyright: (c) Arda Systems 2025-2026, All rights reserved
Copyright: © Arda Systems 2025-2026, All rights reserved