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.
1. Strategic Context
Section titled “1. Strategic Context”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.
Current state
Section titled “Current state”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 viauseSelector. 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”Per-connection-point comparison
Section titled “Per-connection-point comparison”| Connection Point | What TanStack adds | Native React equivalent | Net benefit |
|---|---|---|---|
| Presigned credentials | useMutation with isPending/isError/reset | useState + try/catch in custom hook | Low — one-shot mutation, no caching |
| S3 upload | Nothing — raw XHR with onprogress | Same — XHR either way | None |
| Entity persist | Cache invalidation after mutation | Manual refetch or Redux dispatch | Low — existing Redux pattern |
| Reachability check | useMutation | useState + try/catch | Low — one-shot, no caching |
| External image fetch | useMutation | useState + try/catch | Low — one-shot, no caching |
| CDN cookies | refetchInterval, background tab awareness, auto-retry | setInterval + useState + manual retry | Medium — 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.
Pros for image upload
Section titled “Pros for image upload”- CDN cookie lifecycle —
refetchIntervalwithrefetchIntervalInBackgroundis cleaner than hand-rolledsetInterval. - Declarative state —
isPending,isError,resetout of the box. - DevTools — visibility into queries, mutations, cache state.
Cons for image upload
Section titled “Cons for image upload”- New dependency (+44 KB gzip).
- Second state management paradigm alongside Redux.
- Overkill — 5 of 6 connection points are one-shot mutations.
- 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: PostalAddressThese 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-viewqueryClient.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 firstconst orderQuery = useQuery({ queryKey: orderKeys.detail(orderId), queryFn: () => getOrder(orderId),});
// Load supplier only after order is availableconst 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 orderconst { data: order } = useQuery({ queryKey: orderKeys.detail(orderId), queryFn: () => getOrder(orderId),});
// Child component selects just the delivery addressconst { 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.
3.3 Stale data and cache lifetimes
Section titled “3.3 Stale data and cache lifetimes”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.
4.1 Current AG Grid data flow
Section titled “4.1 Current AG Grid data flow”Redux thunk (fetch) → itemsSlice (store) → page (useSelector) → ArdaGrid (rowData prop) → AG GridCell 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.
5. Native React Alternative at Scale
Section titled “5. Native React Alternative at Scale”For completeness, here is what the native React approach looks like for the complex nested scenarios described above.
5.1 Nested data loading
Section titled “5.1 Nested data loading”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.
5.2 Cache and deduplication
Section titled “5.2 Cache and deduplication”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.
5.3 Scale assessment
Section titled “5.3 Scale assessment”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).
6. Assessment
Section titled “6. Assessment”For image upload alone
Section titled “For image upload alone”| Criterion | TanStack Query | Native React |
|---|---|---|
| Implementation effort | Lower (built-in state management) | Slightly higher (manual state) |
| Bundle size | +44 KB gzip | None |
| CDN cookie lifecycle | Significantly cleaner | Workable (~30 lines) |
| Mutation hooks | Marginal benefit | Equivalent |
| Consistency with existing code | New paradigm | Matches current patterns |
Verdict: Native React is sufficient. TanStack Query is not justified for image upload alone.
For the product roadmap
Section titled “For the product roadmap”| Criterion | TanStack Query | Native React / Redux |
|---|---|---|
| Complex nested data | Hierarchical keys, select, dependent queries | Manual state orchestration per feature |
| Cache and deduplication | Built-in, configurable per query | Not available (Redux has no staleness) |
| Background refresh | refetchInterval, window focus, stale-while-revalidate | Manual setInterval per feature |
| AG Grid integration | Clean — rowData from query, mutations invalidate cache | Works via Redux but manual refresh |
| Typeahead lookups | Cached, deduplicated, stale-time controlled | One fetch per keystroke per editor |
| Optimistic updates | Built-in with rollback | Manual snapshot + restore |
| Consistency across features | Single pattern for all server state | Each feature reinvents loading/error/cache |
| Team investment | Learn once, apply everywhere | Build 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.
Recommended adoption path
Section titled “Recommended adoption path”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.
-
Image upload project — introduce
@tanstack/react-query, implement mutations and CDN cookie query. EstablishesQueryClientProvider, query key conventions, the API functions layer (src/api/), and the hook patterns (src/hooks/queries/,src/hooks/mutations/). Coexists with Redux during transition. -
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).
-
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
useMutationwith cache invalidation. Lookup cell editors use cached queries with deduplication. -
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
Copyright: © Arda Systems 2025-2026, All rights reserved