This commit is contained in:
426
plans/adr-0005-implementation-plan.md
Normal file
426
plans/adr-0005-implementation-plan.md
Normal file
@@ -0,0 +1,426 @@
|
||||
# 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 (
|
||||
<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](../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 <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](../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 <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`
|
||||
|
||||
```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)
|
||||
Reference in New Issue
Block a user