Files
flyer-crawler.projectium.com/plans/adr-0005-implementation-plan.md
Torben Sorensen d356d9dfb6
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 43s
claude 1
2026-01-08 07:47:29 -08:00

12 KiB

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

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) 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 Used By: 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

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

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:

import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { queryClient } from '../config/queryClient';

export const AppProviders = ({ children }) => {
  return (
    <QueryClientProvider client={queryClient}>
      {/* Existing providers */}
      {children}
      {/* Add devtools in development */}
      {import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
    </QueryClientProvider>
  );
};

Phase 2: Replace Custom Hooks with TanStack Query (Days 2-5)

2.1 Replace useInfiniteQuery Hook

Current: src/hooks/useInfiniteQuery.ts Action: Create wrapper around TanStack's useInfiniteQuery

New File: src/hooks/queries/useInfiniteFlyersQuery.ts

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 Action: Simplify to use TanStack Query hook

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 <FlyersContext.Provider value={value}>{children}</FlyersContext.Provider>;
};

Benefits:

  • ~100 lines of code removed
  • Automatic caching
  • Background refetching
  • Request deduplication
  • Optimistic updates support

2.3 Replace UserDataProvider

Current: 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
// 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:

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 <UserDataContext.Provider value={value}>{children}</UserDataContext.Provider>;
};

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

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

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

  • 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