# ADR-0005 Implementation Plan: Frontend State Management with TanStack Query **Date**: 2026-01-08 **Status**: Ready for Implementation **Related ADR**: [ADR-0005: Frontend State Management and Server Cache Strategy](../docs/adr/0005-frontend-state-management-and-server-cache-strategy.md) ## Current State Analysis ### What We Have 1. ✅ **TanStack Query v5.90.12 already installed** in package.json 2. ❌ **Not being used** - Custom hooks reimplementing its functionality 3. ❌ **Custom `useInfiniteQuery` hook** ([src/hooks/useInfiniteQuery.ts](../src/hooks/useInfiniteQuery.ts)) using `useState`/`useEffect` 4. ❌ **Custom `useApiOnMount` hook** (inferred from UserDataProvider) 5. ❌ **Multiple Context Providers** doing manual data fetching ### Current Data Fetching Patterns #### Pattern 1: Custom useInfiniteQuery Hook **Location**: [src/hooks/useInfiniteQuery.ts](../src/hooks/useInfiniteQuery.ts) **Used By**: [src/providers/FlyersProvider.tsx](../src/providers/FlyersProvider.tsx) **Problems**: - Reimplements pagination logic that TanStack Query provides - Manual loading state management - Manual error handling - No automatic caching - No background refetching - No request deduplication #### Pattern 2: useApiOnMount Hook **Location**: Unknown (needs investigation) **Used By**: [src/providers/UserDataProvider.tsx](../src/providers/UserDataProvider.tsx) **Problems**: - Fetches data on mount only - Manual loading/error state management - No caching between unmount/remount - Redundant state synchronization logic ## Implementation Strategy ### Phase 1: Setup TanStack Query Infrastructure (Day 1) #### 1.1 Create QueryClient Configuration **File**: `src/config/queryClient.ts` ```typescript import { QueryClient } from '@tanstack/react-query'; export const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, // 5 minutes gcTime: 1000 * 60 * 30, // 30 minutes (formerly cacheTime) retry: 1, refetchOnWindowFocus: false, refetchOnMount: true, }, mutations: { retry: 0, }, }, }); ``` #### 1.2 Wrap App with QueryClientProvider **File**: `src/providers/AppProviders.tsx` Add TanStack Query provider at the top level: ```typescript import { QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { queryClient } from '../config/queryClient'; export const AppProviders = ({ children }) => { return ( {/* Existing providers */} {children} {/* Add devtools in development */} {import.meta.env.DEV && } ); }; ``` ### Phase 2: Replace Custom Hooks with TanStack Query (Days 2-5) #### 2.1 Replace useInfiniteQuery Hook **Current**: [src/hooks/useInfiniteQuery.ts](../src/hooks/useInfiniteQuery.ts) **Action**: Create wrapper around TanStack's `useInfiniteQuery` **New File**: `src/hooks/queries/useInfiniteFlyersQuery.ts` ```typescript import { useInfiniteQuery } from '@tanstack/react-query'; import * as apiClient from '../../services/apiClient'; export const useInfiniteFlyersQuery = () => { return useInfiniteQuery({ queryKey: ['flyers'], queryFn: async ({ pageParam }) => { const response = await apiClient.fetchFlyers(pageParam); if (!response.ok) { const error = await response.json(); throw new Error(error.message || 'Failed to fetch flyers'); } return response.json(); }, initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, }); }; ``` #### 2.2 Replace FlyersProvider **Current**: [src/providers/FlyersProvider.tsx](../src/providers/FlyersProvider.tsx) **Action**: Simplify to use TanStack Query hook ```typescript import React, { ReactNode, useMemo } from 'react'; import { FlyersContext } from '../contexts/FlyersContext'; import { useInfiniteFlyersQuery } from '../hooks/queries/useInfiniteFlyersQuery'; export const FlyersProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const { data, isLoading, error, fetchNextPage, hasNextPage, isRefetching, refetch, } = useInfiniteFlyersQuery(); const flyers = useMemo( () => data?.pages.flatMap((page) => page.items) ?? [], [data] ); const value = useMemo( () => ({ flyers, isLoadingFlyers: isLoading, flyersError: error, fetchNextFlyersPage: fetchNextPage, hasNextFlyersPage: !!hasNextPage, isRefetchingFlyers: isRefetching, refetchFlyers: refetch, }), [flyers, isLoading, error, fetchNextPage, hasNextPage, isRefetching, refetch] ); return {children}; }; ``` **Benefits**: - ~100 lines of code removed - Automatic caching - Background refetching - Request deduplication - Optimistic updates support #### 2.3 Replace UserDataProvider **Current**: [src/providers/UserDataProvider.tsx](../src/providers/UserDataProvider.tsx) **Action**: Use TanStack Query's `useQuery` for watched items and shopping lists **New Files**: - `src/hooks/queries/useWatchedItemsQuery.ts` - `src/hooks/queries/useShoppingListsQuery.ts` ```typescript // src/hooks/queries/useWatchedItemsQuery.ts import { useQuery } from '@tanstack/react-query'; import * as apiClient from '../../services/apiClient'; export const useWatchedItemsQuery = (enabled: boolean) => { return useQuery({ queryKey: ['watched-items'], queryFn: async () => { const response = await apiClient.fetchWatchedItems(); if (!response.ok) throw new Error('Failed to fetch watched items'); return response.json(); }, enabled, }); }; // src/hooks/queries/useShoppingListsQuery.ts import { useQuery } from '@tanstack/react-query'; import * as apiClient from '../../services/apiClient'; export const useShoppingListsQuery = (enabled: boolean) => { return useQuery({ queryKey: ['shopping-lists'], queryFn: async () => { const response = await apiClient.fetchShoppingLists(); if (!response.ok) throw new Error('Failed to fetch shopping lists'); return response.json(); }, enabled, }); }; ``` **Updated Provider**: ```typescript import React, { ReactNode, useMemo } from 'react'; import { UserDataContext } from '../contexts/UserDataContext'; import { useAuth } from '../hooks/useAuth'; import { useWatchedItemsQuery } from '../hooks/queries/useWatchedItemsQuery'; import { useShoppingListsQuery } from '../hooks/queries/useShoppingListsQuery'; export const UserDataProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const { userProfile } = useAuth(); const isEnabled = !!userProfile; const { data: watchedItems = [], isLoading: isLoadingWatched, error: watchedError } = useWatchedItemsQuery(isEnabled); const { data: shoppingLists = [], isLoading: isLoadingLists, error: listsError } = useShoppingListsQuery(isEnabled); const value = useMemo( () => ({ watchedItems, shoppingLists, isLoading: isEnabled && (isLoadingWatched || isLoadingLists), error: watchedError?.message || listsError?.message || null, }), [watchedItems, shoppingLists, isEnabled, isLoadingWatched, isLoadingLists, watchedError, listsError] ); return {children}; }; ``` **Benefits**: - ~40 lines of code removed - No manual state synchronization - Automatic cache invalidation on user logout - Background refetching ### Phase 3: Add Mutations for Data Modifications (Days 6-8) #### 3.1 Create Mutation Hooks **Example**: `src/hooks/mutations/useAddWatchedItemMutation.ts` ```typescript import { useMutation, useQueryClient } from '@tanstack/react-query'; import * as apiClient from '../../services/apiClient'; import { notifySuccess, notifyError } from '../../services/notificationService'; export const useAddWatchedItemMutation = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: apiClient.addWatchedItem, onSuccess: () => { // Invalidate and refetch watched items queryClient.invalidateQueries({ queryKey: ['watched-items'] }); notifySuccess('Item added to watched list'); }, onError: (error: Error) => { notifyError(error.message || 'Failed to add item'); }, }); }; ``` #### 3.2 Implement Optimistic Updates **Example**: Optimistic shopping list update ```typescript export const useUpdateShoppingListMutation = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: apiClient.updateShoppingList, onMutate: async (newList) => { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: ['shopping-lists'] }); // Snapshot previous value const previousLists = queryClient.getQueryData(['shopping-lists']); // Optimistically update queryClient.setQueryData(['shopping-lists'], (old) => old.map((list) => (list.id === newList.id ? newList : list)) ); return { previousLists }; }, onError: (err, newList, context) => { // Rollback on error queryClient.setQueryData(['shopping-lists'], context.previousLists); notifyError('Failed to update shopping list'); }, onSettled: () => { // Always refetch after error or success queryClient.invalidateQueries({ queryKey: ['shopping-lists'] }); }, }); }; ``` ### Phase 4: Remove Old Custom Hooks (Day 9) #### Files to Remove: - ❌ `src/hooks/useInfiniteQuery.ts` (if not used elsewhere) - ❌ `src/hooks/useApiOnMount.ts` (needs investigation) #### Files to Update: - Update any remaining usages in other components ### Phase 5: Testing & Documentation (Day 10) #### 5.1 Update Tests - Update provider tests to work with QueryClient - Add tests for new query hooks - Add tests for mutation hooks #### 5.2 Update Documentation - Mark ADR-0005 as **Accepted** and **Implemented** - Add usage examples to documentation - Update developer onboarding guide ## Migration Checklist ### Prerequisites - [x] TanStack Query installed - [ ] QueryClient configuration created - [ ] App wrapped with QueryClientProvider ### Queries - [ ] Flyers infinite query migrated - [ ] Watched items query migrated - [ ] Shopping lists query migrated - [ ] Master items query migrated (if applicable) - [ ] Active deals query migrated (if applicable) ### Mutations - [ ] Add watched item mutation - [ ] Remove watched item mutation - [ ] Update shopping list mutation - [ ] Add shopping list item mutation - [ ] Remove shopping list item mutation ### Cleanup - [ ] Remove custom useInfiniteQuery hook - [ ] Remove custom useApiOnMount hook - [ ] Update all tests - [ ] Remove redundant state management code ### Documentation - [ ] Update ADR-0005 status to "Accepted" - [ ] Add usage guidelines to README - [ ] Document query key conventions - [ ] Document cache invalidation patterns ## Benefits Summary ### Code Reduction - **Estimated**: ~300-500 lines of custom hook code removed - **Result**: Simpler, more maintainable codebase ### Performance Improvements - ✅ Automatic request deduplication - ✅ Background data synchronization - ✅ Smart cache invalidation - ✅ Optimistic updates - ✅ Automatic retry logic ### Developer Experience - ✅ React Query Devtools for debugging - ✅ Type-safe query hooks - ✅ Standardized patterns across the app - ✅ Less boilerplate code ### User Experience - ✅ Faster perceived performance (cached data) - ✅ Better offline experience - ✅ Smoother UI interactions (optimistic updates) - ✅ Automatic background updates ## Risk Assessment ### Low Risk - TanStack Query is industry-standard - Already installed in project - Incremental migration possible ### Mitigation Strategies 1. **Test thoroughly** - Maintain existing test coverage 2. **Migrate incrementally** - One provider at a time 3. **Monitor performance** - Use React Query Devtools 4. **Rollback plan** - Keep old code until migration complete ## Timeline Estimate **Total**: 10 working days (2 weeks) - Day 1: Setup infrastructure - Days 2-5: Migrate queries - Days 6-8: Add mutations - Day 9: Cleanup - Day 10: Testing & documentation ## Next Steps 1. Review this plan with team 2. Get approval to proceed 3. Create implementation tickets 4. Begin Phase 1: Setup ## References - [TanStack Query Documentation](https://tanstack.com/query/latest) - [React Query Best Practices](https://tkdodo.eu/blog/practical-react-query) - [ADR-0005 Original Document](../docs/adr/0005-frontend-state-management-and-server-cache-strategy.md)