|
| 1 | +# ADR-004: Migration to TanStack Query |
| 2 | + |
| 3 | +## Status |
| 4 | + |
| 5 | +Accepted |
| 6 | + |
| 7 | +## Context |
| 8 | + |
| 9 | +The application initially used a `useMemo` + `use()` hook pattern for fetching page IDs in `PageTitleInput.tsx`. While functional, this pattern has several issues: |
| 10 | + |
| 11 | +1. **Anti-pattern**: React 19 documentation explicitly warns against creating promises in Client Components |
| 12 | +2. **No request cancellation**: In-flight requests cannot be aborted when dependencies change |
| 13 | +3. **Manual memoization**: Requires careful dependency array management |
| 14 | +4. **Promise recreation**: Every dependency change creates a new promise |
| 15 | +5. **No caching**: Repeated searches for the same title refetch unnecessarily |
| 16 | +6. **Future risk**: React may tighten enforcement of this pattern in future versions |
| 17 | + |
| 18 | +## Decision |
| 19 | + |
| 20 | +Migrate from the `useMemo` + `use()` pattern to TanStack Query with the following architecture: |
| 21 | + |
| 22 | +- Use `useSuspenseQuery` for data fetching (idiomatic React 19 pattern) |
| 23 | +- Use TanStack Pacer's `useDebouncedValue` for input debouncing (300ms) |
| 24 | +- Implement component composition pattern for conditional rendering (since `useSuspenseQuery` doesn't support `enabled` option) |
| 25 | +- Add `AbortSignal` support to `fetchPageId` for proper request cancellation |
| 26 | +- Set 5-minute `staleTime` for automatic caching |
| 27 | + |
| 28 | +## Consequences |
| 29 | + |
| 30 | +### Positive |
| 31 | + |
| 32 | +- **Idiomatic React 19**: Follows official React guidance for data fetching |
| 33 | +- **Automatic caching**: Same title returns cached response (5min staleTime) |
| 34 | +- **Request cancellation**: AbortSignal propagated to fetch, cancels in-flight requests |
| 35 | +- **Promise stability**: Global query cache handles stability automatically |
| 36 | +- **Deduplication**: Multiple components requesting same data share one request |
| 37 | +- **DevTools**: TanStack Query DevTools available for debugging cache/queries |
| 38 | +- **Background refetch**: Configurable stale-while-revalidate behavior |
| 39 | + |
| 40 | +### Negative |
| 41 | + |
| 42 | +- **Bundle size increase**: ~13.91 KB gzipped (70.74 KB → 84.65 KB) |
| 43 | +- **Additional dependency**: Requires TanStack Query and Pacer packages |
| 44 | +- **Increased complexity**: Requires QueryProvider setup and understanding query concepts |
| 45 | +- **Learning curve**: Team needs to understand TanStack Query patterns |
| 46 | + |
| 47 | +### Implementation Details |
| 48 | + |
| 49 | +#### Component Composition Pattern |
| 50 | + |
| 51 | +`PageTitleInput.tsx` always renders the same structure without conditional branching: |
| 52 | + |
| 53 | +```tsx |
| 54 | +<QueryErrorResetBoundary> |
| 55 | + {({ reset }) => ( |
| 56 | + <ErrorBoundary |
| 57 | + resetKeys={[debouncedTitle]} |
| 58 | + onReset={reset} |
| 59 | + fallbackRender={({ error, resetErrorBoundary }) => ( |
| 60 | + // Error UI with retry button and hidden pageId input |
| 61 | + )} |
| 62 | + > |
| 63 | + <Suspense fallback={<LoadingIndicator />}> |
| 64 | + <PageIdFetcher wikiUrl={wikiUrl} pageTitle={debouncedTitle} /> |
| 65 | + </Suspense> |
| 66 | + </ErrorBoundary> |
| 67 | + )} |
| 68 | +</QueryErrorResetBoundary> |
| 69 | +``` |
| 70 | + |
| 71 | +The component uses `react-error-boundary` package for enhanced error handling: |
| 72 | +- **`QueryErrorResetBoundary`**: Coordinates error boundary resets with TanStack Query cache |
| 73 | +- **`resetKeys={[debouncedTitle]}`**: Automatically resets error boundary when title changes |
| 74 | +- **`onReset={reset}`**: Synchronizes with TanStack Query's error state |
| 75 | +- **404 discrimination**: Uses `isNotFoundError()` helper to distinguish 404s from other errors |
| 76 | +- **Retry button**: Displayed only for non-404 errors with inline link styling |
| 77 | +- **Hidden input**: Renders `<input name="pageId">` in error fallback to prevent form submission with invalid page ID |
| 78 | + |
| 79 | +`PageIdFetcher.tsx` handles empty title in `queryFn` to avoid Rules of Hooks violations: |
| 80 | + |
| 81 | +```tsx |
| 82 | +const { data: pageId } = useSuspenseQuery({ |
| 83 | + queryKey: ['pageId', wikiUrl.href, pageTitle], |
| 84 | + queryFn: ({ signal }) => pageTitle ? fetchPageId(wikiUrl, pageTitle, signal) : null, |
| 85 | +}); |
| 86 | +``` |
| 87 | + |
| 88 | +When `pageTitle` is empty, the `queryFn` returns `null` immediately (no network request, no Suspense flash). |
| 89 | + |
| 90 | +#### AbortSignal Propagation |
| 91 | + |
| 92 | +```tsx |
| 93 | +export async function fetchPageId( |
| 94 | + baseUrl: URL, |
| 95 | + pageTitle: string, |
| 96 | + signal: AbortSignal | null = null, |
| 97 | +): Promise<number> { |
| 98 | + const response = await fetch(url, { signal }); |
| 99 | + // ... |
| 100 | +} |
| 101 | +``` |
| 102 | + |
| 103 | +#### Query Configuration |
| 104 | + |
| 105 | +```tsx |
| 106 | +const queryClient = new QueryClient({ |
| 107 | + defaultOptions: { |
| 108 | + queries: { |
| 109 | + staleTime: 5 * 60 * 1000, // 5 minutes |
| 110 | + retry: 1, |
| 111 | + }, |
| 112 | + }, |
| 113 | +}); |
| 114 | +``` |
| 115 | + |
| 116 | +## References |
| 117 | + |
| 118 | +- [React 19 Documentation on use() hook](https://react.dev/reference/react/use) |
| 119 | +- [TanStack Query Documentation](https://tanstack.com/query/latest) |
| 120 | +- [TanStack Pacer Documentation](https://tanstack.com/pacer/latest) |
| 121 | +- [react-error-boundary Documentation](https://github.com/bvaughn/react-error-boundary) |
0 commit comments