Skip to content

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.

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.

// In @arda-cards/design-system — component owns data fetching
import { 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>
);
}
// 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)}
/>
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.

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;
}

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 safety
interface 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.

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.


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.

// 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>
);
}
// 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 };
}
// 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 drilling
function ContactAddressCard({ contactId }: { contactId: string }) {
const { useAddressData } = useOrderProviders();
const { data, isLoading } = useAddressData(contactId);
// renders...
}
// In arda-frontend-app — provide TanStack implementations at page level
function 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>
);
}

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 typeOption A (query key)Option C (typed provider)
Wrong data shapeCaught only if queryFn return type is specificCaught — hook return type must match
Wrong entity ID typeNot caught (key is unknown[])Caught — parameter type enforced
Missing data sourceRuntime error (no queryFn)Caught — required prop/context
Wrong select functionStructural match only (same shape, wrong semantics)Caught — each context has its own hook
Wrong cache keyNot caught at allN/A — no cache keys in component
// In @arda-cards/design-system — mutation contract
interface 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 mutation
function 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.

Because components don’t import TanStack Query, Storybook stories can provide simple mock implementations without QueryClientProvider:

// In ux-prototype story — no TanStack dependency
const 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”
AspectOption A (TanStack-aware)Option C (Typed provider)
Data shape validationPartial — queryFn return type checked, but select can mask errors; queryKey is untypedFull — hook return type must match expected data type at every call site
Entity ID validationNone — query keys are unknown[]Full — hook parameter is typed (string, composite type, etc.)
Assembly-time errorsSome caught by tsc, some deferred to runtime (wrong key, missing cache)All caught by tsc — missing or mistyped provider is a compile error
Refactoring safetyLow — renaming a query key is a string change across call sitesHigh — renaming a hook or changing its signature triggers tsc errors at every consumer
AspectOption AOption C
TanStack dependencyRequired in design system (peerDependency)None in design system
QueryClientProviderRequired everywhere components are usedNot required — Storybook, tests, other apps work without it
Library swapIf TanStack Query is replaced, every component changesIf TanStack Query is replaced, only the app’s hook implementations change
Multi-app reuseAll apps must use TanStack QueryAny app can provide hooks backed by any data source
AspectOption AOption C
Boilerplate per componentLow — useQuery({ queryKey, queryFn }) inlineMedium — define interface, accept hook prop, call hook
Boilerplate per usage siteMedium — construct query key + queryFn per instanceMedium — pass the appropriate hook implementation
DiscoverabilityHigh — component source shows exactly what it fetchesHigh — interface shows exactly what data shape is needed
IDE supportGood — TanStack Query has TypeScript pluginGood — standard TypeScript interfaces
DebuggingTanStack DevTools shows all queries in one panelTanStack DevTools still works (queries are in the app), but component code doesn’t show in the DevTools
AspectOption AOption C
Adding a new data sourceAdd new queryKey/queryFn at call site — component unchangedImplement new hook in app — component unchanged
Changing data shapeUpdate component + all queryFn/select at call sitesUpdate component interface + all hook implementations
Cache strategy changesSpread across component props (staleTime, gcTime, etc.)Centralized in app’s hook implementations
Design system versioningBreaking change if TanStack Query version changesNot affected by TanStack Query version
Testing componentsRequires QueryClientProvider wrapper in every testMock hooks are plain functions — no wrapper needed
AspectOption AOption C
Render optimizationTanStack’s select + structural sharing built-inSame — hooks in app use select internally
Fetch deduplicationAutomatic if same queryKey used across componentsAutomatic — same underlying TanStack queries in app
Bundle size (design system)Includes TanStack Query types in bundleNo TanStack types in bundle
AspectOption AOption C
Prop drillingAvoided — each component fetches its own dataAvoided via Context providers
Parent-child coordinationImplicit via shared cache (same queryKey)Explicit via provider contract
New nested componentJust add useQuery with appropriate keyAdd hook to context interface, implement in app
Orphan queriesPossible — component may fetch data no one invalidatesNot possible — every data source is explicitly wired

5. Concrete Example: AddressCard in Four Contexts

Section titled “5. Concrete Example: AddressCard in Four Contexts”
interface PostalAddress {
street1: string;
street2?: string | null;
city: string;
state: string;
postalCode: string;
country: string;
}
// 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.

// App defines context-specific hooks
function 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 props
interface 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 data
function ItemsPage() {
const { data: items, isLoading } = useQuery({
queryKey: ['items', page],
queryFn: () => fetchItems(page),
});
return <ArdaEntityDataGrid rowData={items ?? []} loading={isLoading} ... />;
}

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;
}

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:

AspectFactory (Pattern A)Context (Pattern B)
Type safety per columnStrong — each factory call is type-checkedWeakerRecord<string, LookupHook> loses per-field typing; wrong field name is a runtime error
BoilerplateOne createXxx() call per columnOne context provider per grid
DiscoverabilityExplicit — column def shows exactly which hookImplicit — must read context to know what’s available
Adding a new lookup columnAdd factory call in column defAdd key to lookups record
Mismatched hooktsc error at column defRuntime error (key not found)
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 LookupHook with 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 it
interface 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 SSRM
interface 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-app
const 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.


Decision FD-01 (recorded in decision-log.md): Option C (mandatory typed providers) for all design system components in ux-prototype. Option A (inline useQuery/useMutation) acceptable for app-specific local components in arda-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.

FactorFavors Option AFavors Option C
Type safety at assembly timeStrong — all errors at tsc
Design system portabilityStrong — no TanStack coupling
Storybook/testing simplicityStrong — no QueryClientProvider needed
Less boilerplate per componentModerate — inline useQuery
Fewer files to maintainModerate — no separate hook layer
TanStack DevTools visibilityMinor — queries visible in component code
Cache strategy centralizationStrong — staleTime/gcTime in app, not component
Refactoring safetyStrong — rename propagates via types
Library swap resilienceStrong — only app hooks change

Option C (Typed Data Provider Strategy) is recommended for the design system (ux-prototype). The primary reasons:

  1. Type safety is non-negotiable for a shared component library. Errors must be caught at tsc time, not at runtime. Option A’s reliance on unknown[] query keys and untyped select functions creates a class of errors that bypass the compiler.

  2. 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.

  3. 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.

  4. The cost is manageable. The additional hook layer in arda-frontend-app is 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.

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/data props.
  • 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 rowData as a prop (app provides via TanStack Query). Future SSRM uses a typed ServerRowModelDataSource contract.
Component locationBinding approachData fetching
Design system (ux-prototype) — all componentsOption C: typed providersNever imports TanStack; accepts typed hooks via props/context/factory
Design system — AG Grid cell editorsOption C via factory functionsFactory closes over typed hooks; column def is the assembly point
App-specific pages (arda-frontend-app)Option A: inline TanStackDirect useQuery/useMutation — not reused outside the app
App-specific wiring hooks (arda-frontend-app)TanStack implementations of design system contractsImplements typed hook interfaces with TanStack Query

Copyright: (c) Arda Systems 2025-2026, All rights reserved