Skip to content

TanStack Query Adoption Exploration

Strategic analysis of adopting TanStack Query for server-state management in arda-frontend-app. Evaluates the library against native React patterns for both the immediate image upload project and the broader product roadmap.

The image upload project is one of many upcoming features that require sophisticated server-state management. Future projects involve complex domain objects with deep nesting (e.g., Orders with nested Suppliers, Addresses, Contacts), AG Grid–based table views with inline editing, and multiple concurrent data-fetching concerns.

The decision to adopt TanStack Query is not scoped to image upload alone — it is a platform decision about how arda-frontend-app manages server state going forward.

The app currently uses Redux Toolkit + redux-persist for all state:

  • Client state (UI preferences, form drafts, auth tokens) — a natural fit for Redux.
  • Server state (items, kanban cards, lookups) — fetched via Redux thunks in src/lib/ardaClient.ts, stored in Redux slices, and selected via useSelector. This works but conflates server data cache with client state, requiring manual cache invalidation, loading/error tracking per slice, and no built-in staleness or background refresh.

Important: the current implementation is not normative

Section titled “Important: the current implementation is not normative”

The existing Redux-based architecture is not a constraint on future direction. It reflects choices made early in the product when the feature set was simpler (flat entity CRUD, single-level grids). As the product evolves toward complex domain objects (Orders, Business Affiliates with nested structures), multi-view coordination, and real-time data concerns, significant architectural evolution is expected. The goal is to adopt current best practices with TanStack Query and React — not to preserve compatibility with the existing Redux thunk patterns. New features should use the target architecture; existing features can be migrated incrementally.


2. TanStack Query vs. Native React for Image Upload

Section titled “2. TanStack Query vs. Native React for Image Upload”
Connection PointWhat TanStack addsNative React equivalentNet benefit
Presigned credentialsuseMutation with isPending/isError/resetuseState + try/catch in custom hookLow — one-shot mutation, no caching
S3 uploadNothing — raw XHR with onprogressSame — XHR either wayNone
Entity persistCache invalidation after mutationManual refetch or Redux dispatchLow — existing Redux pattern
Reachability checkuseMutationuseState + try/catchLow — one-shot, no caching
External image fetchuseMutationuseState + try/catchLow — one-shot, no caching
CDN cookiesrefetchInterval, background tab awareness, auto-retrysetInterval + useState + manual retryMedium — background lifecycle management

For image upload in isolation, TanStack Query provides marginal benefit over native React custom hooks. The CDN cookie lifecycle is the only connection point where the library adds significant value.

  1. CDN cookie lifecyclerefetchInterval with refetchIntervalInBackground is cleaner than hand-rolled setInterval.
  2. Declarative stateisPending, isError, reset out of the box.
  3. DevTools — visibility into queries, mutations, cache state.
  1. New dependency (+44 KB gzip).
  2. Second state management paradigm alongside Redux.
  3. Overkill — 5 of 6 connection points are one-shot mutations.
  4. CDN cookies could be ~30 lines with native React.

3. Beyond Image Upload: Complex Domain Objects

Section titled “3. Beyond Image Upload: Complex Domain Objects”

Future features involve deeply nested domain objects. An Order entity, for example:

Order
├── supplier: BusinessAffiliate
│ ├── companyInformation: CompanyInformation
│ │ └── postalAddress: PostalAddress
│ ├── roles[]: BusinessRole
│ │ └── contacts[]: Contact
│ │ └── address: PostalAddress
│ └── geoLocation: GeoLocation
├── items[]: OrderLine
│ ├── item: Item (reference)
│ └── supply: ItemSupply (reference)
├── deliveryAddress: PostalAddress
└── billingAddress: PostalAddress

These structures require loading data from multiple endpoints, managing dependent fetches, keeping nested views in sync after mutations, and handling partial updates to deeply nested fields.

3.1 TanStack Query patterns for nested data

Section titled “3.1 TanStack Query patterns for nested data”

Hierarchical query keys — TanStack Query’s key structure maps naturally to nested domain objects and enables scoped invalidation:

const orderKeys = {
all: ['orders'] as const,
lists: () => [...orderKeys.all, 'list'] as const,
detail: (id: string) => [...orderKeys.all, 'detail', id] as const,
supplier: (id: string) => [...orderKeys.detail(id), 'supplier'] as const,
lines: (id: string) => [...orderKeys.detail(id), 'lines'] as const,
};
// Invalidate everything about an order (detail + supplier + lines)
queryClient.invalidateQueries({ queryKey: orderKeys.detail(orderId) });
// Invalidate only the supplier sub-view
queryClient.invalidateQueries({ queryKey: orderKeys.supplier(orderId) });
// Invalidate all order lists (after any order mutation)
queryClient.invalidateQueries({ queryKey: orderKeys.lists() });

Dependent queries — load nested data that depends on a parent query:

// Load order first
const orderQuery = useQuery({
queryKey: orderKeys.detail(orderId),
queryFn: () => getOrder(orderId),
});
// Load supplier only after order is available
const supplierQuery = useQuery({
queryKey: orderKeys.supplier(orderId),
queryFn: () => getBusinessAffiliate(orderQuery.data!.supplier.eId),
enabled: !!orderQuery.data?.supplier?.eId,
});

Select/transform — derive sub-views from a parent query without separate fetches. Multiple components can select different slices of the same cached data:

// Parent component loads the full order
const { data: order } = useQuery({
queryKey: orderKeys.detail(orderId),
queryFn: () => getOrder(orderId),
});
// Child component selects just the delivery address
const { data: deliveryAddress } = useQuery({
queryKey: orderKeys.detail(orderId),
queryFn: () => getOrder(orderId),
select: (order) => order.deliveryAddress,
});
// No additional fetch — reads from cache. Re-renders only when
// deliveryAddress changes (structural equality check on select output).

Optimistic updates — immediate UI updates before server confirmation, with automatic rollback on error:

const updateOrderLine = useMutation({
mutationFn: (vars) => api.updateOrderLine(vars),
onMutate: async (vars) => {
await queryClient.cancelQueries({ queryKey: orderKeys.detail(orderId) });
const previous = queryClient.getQueryData(orderKeys.detail(orderId));
// Optimistically update the cached order with the new line data
queryClient.setQueryData(orderKeys.detail(orderId), (old) => ({
...old,
lines: old.lines.map(l => l.eId === vars.lineId ? { ...l, ...vars.input } : l),
}));
return { previous }; // Rollback context
},
onError: (_err, _vars, context) => {
// Rollback to previous state
queryClient.setQueryData(orderKeys.detail(orderId), context?.previous);
},
onSettled: () => {
// Refetch to get the authoritative server state
queryClient.invalidateQueries({ queryKey: orderKeys.detail(orderId) });
},
});

Parallel queries — load multiple independent sub-resources concurrently:

const results = useQueries({
queries: [
{ queryKey: orderKeys.detail(orderId), queryFn: () => getOrder(orderId) },
{ queryKey: orderKeys.lines(orderId), queryFn: () => getOrderLines(orderId) },
{ queryKey: ['business-affiliate', supplierId], queryFn: () => getBA(supplierId) },
],
});

Placeholder and initial data — use parent data as placeholder while sub-queries load, avoiding layout shift:

const { data: supplier } = useQuery({
queryKey: ['business-affiliate', supplierId],
queryFn: () => getBusinessAffiliate(supplierId),
// Show the summary from the order while full supplier loads
placeholderData: () => {
const order = queryClient.getQueryData(orderKeys.detail(orderId));
return order?.supplier; // Partial data, rendered immediately
},
});

3.2 Component composition with TanStack Query

Section titled “3.2 Component composition with TanStack Query”

In a deeply nested component tree, TanStack Query’s shared cache prevents waterfall fetches and prop drilling:

OrderPage
├── OrderHeader ← useQuery(orderKeys.detail(id))
├── SupplierSection ← useQuery(orderKeys.detail(id), select: supplier)
│ ├── CompanyInfo ← useQuery(['ba', supplierId], select: companyInfo)
│ ├── ContactList ← useQuery(['ba', supplierId], select: contacts)
│ │ └── ContactCard ← select: contacts[i]
│ └── AddressList ← useQuery(['ba', supplierId], select: addresses)
├── OrderLinesGrid ← useQuery(orderKeys.lines(id))
│ └── AG Grid ← rowData from query
└── DeliveryAddress ← useQuery(orderKeys.detail(id), select: deliveryAddress)

Each component declares what data it needs. TanStack Query deduplicates fetches — if OrderHeader and DeliveryAddress both query orderKeys.detail(id), only one fetch happens. Components re-render only when their selected slice changes.

Contrast with the current Redux pattern: Today, the parent page dispatches a thunk, stores the result in a Redux slice, and every nested component reads from the slice via useSelector. This works but couples all components to the Redux store shape, requires the parent to know what data all children need, and makes independent refetching (e.g., refresh just the supplier) awkward.

For complex views where the user navigates between detail panels, TanStack Query’s cache provides instant navigation:

  • staleTime — data is considered fresh for N minutes. Navigating back to a previously viewed order shows cached data immediately with no loading state, then refetches in the background if stale.
  • gcTime (garbage collection) — cached data is retained for N minutes after the last component unmounts. Avoids re-fetching when navigating back and forth between list and detail views.
  • Background refetch on window focus — when the user returns to the tab, stale queries automatically refresh.

The current Redux approach has no concept of staleness — data stays in the store until explicitly replaced. There is no automatic background refresh or cache expiration.


4. AG Grid Integration with TanStack Query

Section titled “4. AG Grid Integration with TanStack Query”

AG Grid is the standard for all table views in arda-frontend-app. The current pattern (ArdaGrid component) uses AG Grid’s client-side row model with rowData passed as a prop from Redux.

Redux thunk (fetch) → itemsSlice (store) → page (useSelector) → ArdaGrid (rowData prop) → AG Grid

Cell edits follow a complex path: AG Grid’s onCellValueChanged → draft creation via ardaClient.createDraftItem() → accumulate changes → publish via ardaClient.updateItem() → refresh grid. This logic lives in ItemTableAGGrid.tsx (~800 lines) with manual state tracking for pending drafts, unsaved changes, and error handling.

4.2 AG Grid + TanStack Query: Client-side row model

Section titled “4.2 AG Grid + TanStack Query: Client-side row model”

The simplest integration keeps AG Grid’s client-side row model but replaces Redux as the data source:

function ItemsGrid() {
// TanStack Query owns the data
const { data: items, isLoading, error } = useQuery({
queryKey: ['items', currentPage],
queryFn: () => fetchItems(currentPage),
});
// AG Grid renders it
return (
<ArdaGrid
rowData={items ?? []}
loading={isLoading}
error={error?.message}
columnDefs={columnDefs}
/>
);
}

After a cell edit mutation:

const updateItem = useMutation({
mutationFn: (vars) => api.updateItem(vars.entityId, vars.input),
onSuccess: () => {
// Invalidate the items list — AG Grid re-renders with fresh data
queryClient.invalidateQueries({ queryKey: ['items'] });
},
});

This eliminates the manual draft tracking and refresh logic currently in ItemTableAGGrid.tsx.

4.3 AG Grid cell editors with TanStack mutations

Section titled “4.3 AG Grid cell editors with TanStack mutations”

AG Grid cell editors can use TanStack mutations for save operations. The pattern parallels the existing ImageCellEditor:

function SupplierCellEditor({ value, data, stopEditing }: ICellEditorParams) {
const updateMutation = useMutation({
mutationFn: (newValue: string) =>
api.updateItem(data.entityId, { ...data, primarySupply: { supplier: newValue } }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['items'] });
stopEditing(false);
},
onError: () => {
toast.error('Failed to update supplier');
stopEditing(true); // Cancel
},
});
// ... render editor UI, call updateMutation.mutate(newValue) on confirm
}

4.4 AG Grid server-side row model (future)

Section titled “4.4 AG Grid server-side row model (future)”

For large datasets, AG Grid’s Server-Side Row Model (SSRM) delegates pagination and sorting to the server. TanStack Query can back the data source:

const serverDatasource: IServerSideDatasource = {
getRows: async (params) => {
const result = await queryClient.fetchQuery({
queryKey: ['items', 'ssrm', params.request],
queryFn: () => fetchItemsPage(params.request),
staleTime: 60_000,
});
params.success({ rowData: result.rows, rowCount: result.total });
},
};

This gives SSRM the benefits of TanStack Query’s caching — scrolling back to a previously loaded page uses cached data instead of re-fetching.

4.5 AG Grid + lookups (typeahead cell editors)

Section titled “4.5 AG Grid + lookups (typeahead cell editors)”

The current codebase has 9 typeahead cell editors (SupplierCellEditor, TypeCellEditor, FacilityCellEditor, etc.) that call ardaClient.lookupXxx() functions. With TanStack Query, these become cached queries with deduplication:

function useSupplierLookup(query: string) {
return useQuery({
queryKey: ['lookups', 'suppliers', query],
queryFn: () => api.lookupSuppliers(query),
enabled: query.length >= 2,
staleTime: 5 * 60 * 1000, // Lookup results cached for 5 min
});
}

If two cells trigger the same lookup query concurrently (e.g., two rows being edited), TanStack Query deduplicates to a single fetch. The results are cached so repeated edits reuse previous lookups without network calls.


For completeness, here is what the native React approach looks like for the complex nested scenarios described above.

Without TanStack Query, dependent fetches require manual orchestration:

function useOrderWithSupplier(orderId: string) {
const [order, setOrder] = useState(null);
const [supplier, setSupplier] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
getOrder(orderId)
.then((o) => {
if (cancelled) return;
setOrder(o);
if (o.supplier?.eId) {
return getBusinessAffiliate(o.supplier.eId);
}
})
.then((s) => {
if (cancelled) return;
if (s) setSupplier(s);
setLoading(false);
})
.catch((e) => {
if (cancelled) return;
setError(e);
setLoading(false);
});
return () => { cancelled = true; };
}, [orderId]);
return { order, supplier, loading, error };
}

This works but has no caching, no deduplication, no staleness tracking, no background refresh, and no scoped invalidation. Each new feature requires building these capabilities from scratch or accepting their absence.

Without a query cache, two components that need the same data either:

  • Share via props — requires prop drilling or context, coupling parent to children’s data needs.
  • Fetch independently — duplicate network requests.
  • Use Redux — centralized store, but requires manual cache invalidation and no staleness concept.

For a few isolated features (like image upload), native React custom hooks are sufficient and simpler. As the number of features with server-state requirements grows — each with their own loading/error/refresh/invalidation needs — the manual approach produces significant boilerplate and inconsistency across features.

TanStack Query provides a standardized, tested solution for these concerns. The alternative is to build an in-house equivalent, which would converge on the same patterns but with maintenance burden and without the community ecosystem (DevTools, testing utilities, documentation).


CriterionTanStack QueryNative React
Implementation effortLower (built-in state management)Slightly higher (manual state)
Bundle size+44 KB gzipNone
CDN cookie lifecycleSignificantly cleanerWorkable (~30 lines)
Mutation hooksMarginal benefitEquivalent
Consistency with existing codeNew paradigmMatches current patterns

Verdict: Native React is sufficient. TanStack Query is not justified for image upload alone.

CriterionTanStack QueryNative React / Redux
Complex nested dataHierarchical keys, select, dependent queriesManual state orchestration per feature
Cache and deduplicationBuilt-in, configurable per queryNot available (Redux has no staleness)
Background refreshrefetchInterval, window focus, stale-while-revalidateManual setInterval per feature
AG Grid integrationClean — rowData from query, mutations invalidate cacheWorks via Redux but manual refresh
Typeahead lookupsCached, deduplicated, stale-time controlledOne fetch per keystroke per editor
Optimistic updatesBuilt-in with rollbackManual snapshot + restore
Consistency across featuresSingle pattern for all server stateEach feature reinvents loading/error/cache
Team investmentLearn once, apply everywhereBuild and maintain custom infrastructure

Verdict: TanStack Query is justified as a platform investment. The image upload project is a reasonable starting point because it introduces the library with straightforward use cases (mutations + one polling query) before tackling the more complex patterns (nested data, optimistic updates, SSRM) in subsequent projects.

The current Redux-based implementation is not normative — the architecture is expected to evolve. New features should use TanStack Query from the start; existing features migrate incrementally.

  1. Image upload project — introduce @tanstack/react-query, implement mutations and CDN cookie query. Establishes QueryClientProvider, query key conventions, the API functions layer (src/api/), and the hook patterns (src/hooks/queries/, src/hooks/mutations/). Coexists with Redux during transition.

  2. Next feature with nested data (e.g., Order management) — use TanStack Query as the primary data-fetching layer from the start. Dependent queries, select transforms, cache invalidation, optimistic updates. Redux used only for client-only state (auth, UI preferences).

  3. Grid evolution — new grid views (Orders, Business Affiliates) use TanStack Query natively. Existing item grid migrated from Redux thunks to TanStack Query. Cell editor mutations use useMutation with cache invalidation. Lookup cell editors use cached queries with deduplication.

  4. Redux scope reduction — as features adopt TanStack Query for server state, Redux slices for server data (itemsSlice, scanSlice) are retired. Redux retains its natural role: client-only state (auth tokens, UI preferences, form drafts, persisted settings).


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