Skip to content

Migrate to TanStack Query for page ID fetching#157

Merged
Wintus merged 9 commits intotrunkfrom
feat/tanstack-query-migration
Feb 5, 2026
Merged

Migrate to TanStack Query for page ID fetching#157
Wintus merged 9 commits intotrunkfrom
feat/tanstack-query-migration

Conversation

@Wintus
Copy link
Owner

@Wintus Wintus commented Feb 3, 2026

Summary

Migrates page ID fetching from useMemo + use() pattern to TanStack Query's useSuspenseQuery, addressing the anti-pattern identified in React documentation.

Closes #155

Changes

Core Migration

  • PageIdFetcher: Migrated to useSuspenseQuery with query key ['pageId', wikiUrl.href, pageTitle]
  • PageTitleInput: Replaced custom useDebounce with TanStack Pacer's useDebouncedValue (300ms)
  • MediaWikiAPIs: Added AbortSignal parameter to fetchPageId for request cancellation
  • App: Added QueryClientProvider with 5min staleTime and ReactQueryDevtools

Cleanup

  • Removed src/hooks/useDebounce.ts (replaced by TanStack Pacer)
  • Updated all tests to use QueryClientProvider wrapper

Documentation

  • Added ADR-004: TanStack Query migration decision record
  • Updated CLAUDE.md: Added TanStack Query patterns and testing requirements

Key Benefits

Request cancellation: In-flight requests automatically aborted on input change
Automatic caching: Same page title returns cached result (5min staleTime)
Deduplication: Multiple components requesting same data share one request
Promise stability: Query cache handles promise reference stability
DevTools: ReactQueryDevtools for debugging

Technical Details

Query Configuration

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5 minutes
      retry: 1,
    },
  },
});

Empty Title Handling

Empty titles are handled inside queryFn by returning null immediately (no Suspense flash).

Testing Pattern

All components using queries must wrap in QueryClientProvider:

const queryClient = new QueryClient({
  defaultOptions: { queries: { retry: false } },
});

render(
  <QueryClientProvider client={queryClient}>
    {component}
  </QueryClientProvider>
);

Trade-offs

Aspect Impact
Bundle size +~13.91KB gzipped
Complexity QueryProvider setup required in App and tests
Error handling Requires ErrorBoundary

Test Plan

  • All existing tests pass with QueryClientProvider wrapper
  • Page ID fetching works with debounced input
  • Request cancellation on rapid input changes
  • Empty title returns null without Suspense flash
  • Error boundary catches fetch failures
  • Cache works (same title = cached response)

Co-Authored-By: Claude
Editor: me

@Wintus Wintus force-pushed the feat/tanstack-query-migration branch from b0b0920 to 75f072d Compare February 3, 2026 11:17
@claude

This comment was marked as outdated.

@Wintus Wintus self-assigned this Feb 3, 2026
@Wintus Wintus added the enhancement New feature or request label Feb 3, 2026
@Wintus Wintus marked this pull request as draft February 3, 2026 11:30
@Wintus
Copy link
Owner Author

Wintus commented Feb 3, 2026

  1. ReactQueryDevtools in Production Build ⚠️

False positive. See https://tanstack.com/query/latest/docs/framework/react/devtools#install-and-import-the-devtools

@claude

This comment was marked as outdated.

@Wintus Wintus force-pushed the feat/tanstack-query-migration branch from 0c31581 to ddf9961 Compare February 4, 2026 04:26
@Wintus Wintus marked this pull request as ready for review February 4, 2026 04:27
@claude

This comment was marked as outdated.

- Configure QueryClient with 5min staleTime
- Add ReactQueryDevtools for development
- Wrap App in QueryClientProvider

Co-Authored-By: Claude
- Add optional signal parameter to fetchPageId
- Pass signal to fetch for request cancellation
- Add tests for abort behavior

Co-Authored-By: Claude
@Wintus Wintus force-pushed the feat/tanstack-query-migration branch from ddf9961 to b94f125 Compare February 4, 2026 10:17
@claude
Copy link

claude bot commented Feb 4, 2026

Code Review: TanStack Query Migration

I've thoroughly reviewed PR #157 and this is an excellent migration that significantly improves the codebase. All tests are passing, the implementation is clean, and the documentation is comprehensive. Here's my detailed feedback:


✅ Strengths

Architecture & Design

  • Excellent ADR-004: Clear documentation of the migration rationale, trade-offs, and implementation details
  • Proper error handling: The QueryErrorResetBoundary + ErrorBoundary + Suspense composition is the correct React 19 pattern
  • Smart 404 discrimination: The isNotFoundError() helper appropriately distinguishes user errors (404) from retriable errors (network/5XX)
  • Correct AbortSignal propagation: Proper request cancellation with signal parameter throughout the chain
  • Functional programming adherence: Maintains the codebase's functional style (pure functions, dependency injection)

Code Quality

  • Comprehensive test coverage: All updated components include proper test wrappers with QueryClientProvider
  • Test patterns updated correctly:
    • Tests for retry button behavior
    • Tests for 404 vs network error handling
    • Tests for error recovery on title change
    • Proper use of renderWithQuery helper
  • CSS improvements: Inline link styling for retry button (.status-message-container button) is appropriate
  • Clean removal: useDebounce.ts properly replaced with TanStack Pacer's useDebouncedValue

Performance

  • Optimal caching: 1-hour staleTime is correct for immutable page IDs
  • Request deduplication: Multiple components won't refetch the same data
  • Automatic cancellation: In-flight requests properly aborted on input change

🔍 Minor Observations & Suggestions

1. Bundle Size Documentation

Location: ADR-004, line 42
Current: States ~13.91 KB gzipped increase

Observation: This is documented in the ADR, which is good. For a feature like this, the bundle size increase is justified by the significant UX improvements (request cancellation, caching, deduplication). No action needed, but consider monitoring this in future PRs.

2. Query Key Structure

Location: PageIdFetcher.tsx:526

queryKey: ['pageId', wikiUrl.href, pageTitle]

Observation: Using wikiUrl.href is correct and ensures proper cache isolation between different Wikipedia sites. Good choice! Alternative patterns like [wikiUrl.hostname, pageTitle] would work but .href is more explicit.

3. Empty Title Handling

Location: PageIdFetcher.tsx:528

queryFn: ({ signal }) => pageTitle ? fetchPageId(wikiUrl, pageTitle, signal) : null,

Observation: This is the correct approach to avoid Suspense flash. The ternary inside queryFn ensures the hook is always called (Rules of Hooks compliance) while avoiding network requests for empty titles. Well done!

4. Error Boundary Reset Pattern

Location: PageTitleInput.tsx:731-732

resetKeys={[debouncedTitle]}
onReset={reset}

Observation: Excellent use of resetKeys for automatic error recovery. This creates a smooth UX where typing a new title automatically clears previous errors.

5. Hidden Input in Error Fallback

Location: PageTitleInput.tsx:749-753

<input type="hidden" name="pageId" value="" data-testid="pageId-input" />

Observation: Critical detail! This prevents form submission when page ID fetch fails. Without this, the form could submit with a missing pageId, causing validation errors in searchAction. Great defensive programming.


🎯 Suggestions (Optional Enhancements)

1. Consider Adding Query Retry Configuration

While you have retry: 1 globally, consider whether 404 errors should retry at all:

// Optional: in PageIdFetcher.tsx
const { data: pageId } = useSuspenseQuery({
  queryKey: ['pageId', wikiUrl.href, pageTitle],
  queryFn: ({ signal }) => pageTitle ? fetchPageId(wikiUrl, pageTitle, signal) : null,
  retry: (failureCount, error) => {
    // Don't retry 404s
    if (isNotFoundError(error)) return false;
    return failureCount < 1;
  },
});

Rationale: 404s won't succeed on retry, so this could reduce unnecessary network requests. However, the current implementation is already correct with the global retry: 1, so this is purely optional.

2. Consider ReactQueryDevtools Positioning

Location: App.tsx:500

The ReactQueryDevtools is currently at the bottom of the component tree. This is fine, but some teams prefer it outside the main app structure for better separation:

<QueryClientProvider client={queryClient}>
  {/* app content */}
  {import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>

The conditional ensures it's only included in dev builds (though Vite likely tree-shakes this anyway).


🔒 Security

No security concerns identified

  • AbortSignal properly prevents race conditions
  • No XSS risks introduced
  • URL construction remains safe
  • No credential or sensitive data exposure

📊 Performance Considerations

Performance improvements confirmed:

  1. Reduced network requests: Caching prevents redundant fetches
  2. Faster subsequent searches: 1-hour cache for page IDs
  3. Request cancellation: Prevents wasted bandwidth on rapid typing
  4. Deduplication: Multiple components share requests

⚠️ Bundle size trade-off: +13.91 KB gzipped is acceptable for the features gained.


🧪 Test Coverage

Excellent test coverage:

  • ✅ All components using queries wrapped in QueryClientProvider
  • ✅ Error boundary retry button tested
  • ✅ 404 vs network error discrimination tested
  • ✅ Error recovery on title change tested
  • ✅ AbortSignal propagation tested
  • ✅ Empty title handling tested
  • ✅ Debounce behavior tested

📝 Documentation

Outstanding documentation:

  • ✅ ADR-004 comprehensively covers the migration
  • ✅ CLAUDE.md updated with TanStack Query patterns
  • ✅ Testing patterns documented
  • ✅ Clear code comments where needed

Final Verdict

Status: ✅ APPROVED - Ready to merge

This is a high-quality PR that:

  1. Addresses a real anti-pattern (useMemo + use())
  2. Follows React 19 best practices
  3. Maintains the functional programming style
  4. Includes comprehensive tests
  5. Has excellent documentation

The bundle size increase is justified by significant UX and DX improvements. No blocking issues found.

Recommendation: Merge this PR. The suggested enhancements above are purely optional refinements, not blockers.


Great work on this migration! The attention to detail (404 discrimination, hidden input in error fallback, resetKeys pattern) shows solid React expertise. 🎉

- Replace use() hook with useSuspenseQuery
- Accept wikiUrl and pageTitle props instead of promise
- Pass AbortSignal from query to fetchPageId
- Update tests to use QueryClientProvider wrapper

Co-Authored-By: Claude
@Wintus Wintus force-pushed the feat/tanstack-query-migration branch from b94f125 to 3a567c6 Compare February 4, 2026 12:51
@claude

This comment was marked as outdated.

- Replace custom useDebounce with useDebouncedValue from @tanstack/react-pacer
- Remove useMemo promise creation pattern
- Use conditional rendering for PageIdFetcher (not enabled option)
- Add ErrorBoundary component for query errors
- Update all tests to use QueryClientProvider wrapper

Replace custom ErrorBoundary class with react-error-boundary library
and add QueryErrorResetBoundary to properly reset both React and
TanStack Query error states.

Key improvements:
- Install react-error-boundary (v6.1.0)
- Use QueryErrorResetBoundary + ErrorBoundary combination
- Use resetKeys={[debouncedTitle]} instead of key prop (no unmount)
- Distinguish 404 vs transient errors via error.cause.status
- 404 errors show "Page not found." (no retry)
- Transient errors show retry button with type="button"
- Add comprehensive tests for all error scenarios

Fixes the two-layer stuck state where neither React's error boundary
nor TanStack Query's cached error would reset on retry attempts.

Co-Authored-By: Claude
All 15 SearchForm tests were logging "No QueryClient set" errors to stderr because PageIdFetcher (rendered via PageTitleInput) uses useSuspenseQuery. Added QueryClientProvider wrapping to all test renders following the established pattern from PageIdFetcher and PageTitleInput tests.

Co-Authored-By: Claude
Style the retry button in error messages as an inline text link
instead of a block button, for better visual integration with
the error message text.

- Display inline with inherited font properties
- Use primary color with underline (link-like appearance)
- Remove block-level button styling for this context

Co-Authored-By: Claude
Replaced by TanStack Pacer's useDebouncedValue

Co-Authored-By: Claude
- Document migration rationale and trade-offs
- Add TanStack Query patterns to CLAUDE.md
- Include testing patterns and key benefits

Co-Authored-By: Claude
@Wintus Wintus force-pushed the feat/tanstack-query-migration branch from 3a567c6 to feed0d0 Compare February 4, 2026 15:13
@claude
Copy link

claude bot commented Feb 4, 2026

Code Review Summary

This PR implements a well-architected migration from the useMemo + use() pattern to TanStack Query. All CI checks are passing ✅ (Lint, Test, Type Check). The implementation is thorough, well-tested, and follows React 19 best practices.


✅ Strengths

1. Architecture & Design

  • Solid motivation: Addresses a documented React anti-pattern with clear justification
  • Excellent ADR-004: Comprehensive documentation of decision rationale, trade-offs, and implementation details
  • Clean separation: PageIdFetcher handles data fetching, PageTitleInput handles UI/interactions
  • Component composition: Smart use of QueryErrorResetBoundary > ErrorBoundary > Suspense hierarchy

2. Error Handling

  • 404 discrimination: isNotFoundError() helper distinguishes 404s from retryable errors
  • User-friendly UX: Retry button for transient errors, simple message for 404s
  • Inline button styling: CSS styling (App.css:410-426) makes retry button look like a link
  • Form validation: Hidden pageId input in error fallback prevents invalid submissions
  • Automatic recovery: resetKeys={[debouncedTitle]} auto-resets errors when user types new title

3. Test Coverage

  • Comprehensive tests: All components updated with QueryClientProvider wrapper
  • Retry behavior tested: Verifies retry button functionality and error recovery
  • Edge cases covered: Empty title, 404s, network errors, debouncing
  • Abort signal tested: Verifies request cancellation on input changes
  • All tests passing: CI confirms complete test suite passes

4. Performance Optimizations

  • Request cancellation: AbortSignal properly propagated through the stack
  • Smart caching: 1-hour staleTime appropriate for immutable page IDs
  • Deduplication: Multiple components requesting same data share one request
  • Empty title handling: Returns null immediately in queryFn (no Suspense flash)

5. Code Quality

  • Functional programming style: Follows project conventions (pure functions, no classes)
  • Type safety: Proper TypeScript usage throughout
  • Consistent patterns: All tests use same renderWithQuery helper
  • Clean imports: Added dependencies properly declared in package.json

🔍 Observations & Suggestions

1. Bundle Size Trade-off

The PR notes a +12KB gzipped increase (PR description) but ADR-004 says ~13.91KB. This discrepancy is minor but worth reconciling.

Impact: For this use case, the bundle increase is justified given the benefits (caching, cancellation, DevTools). However, consider:

  • Tree-shaking: TanStack Query is well tree-shaken, but verify with npm run build + bundle analyzer
  • Alternative: For simple projects, a lightweight custom hook might suffice, but TanStack Query is the right choice here given the complexity

2. Retry Configuration Inconsistency

In PageIdFetcher.tsx:533:

retry: (failureCount, error) => !isNotFoundError(error) && failureCount < 1,

This conflicts with the global retry: 1 setting in App.tsx:454. The custom retry logic is good, but the comment in ADR-004 says "Single retry on failure" while the code implements "no retry for 404s, 1 retry for others".

Recommendation: This is correct behavior, but the documentation could be clearer:

  • Update ADR-004 to mention the custom retry logic for 404s
  • Consider extracting retry logic to a shared constant for reusability

3. Error Boundary Reset Behavior

The resetKeys={[debouncedTitle]} pattern is clever, but consider edge cases:

  • Rapid typing: If user types rapidly, the debounced value might not update fast enough
  • Same title after error: If user deletes characters then retypes the same title, the error boundary won't reset

Current behavior is acceptable, but you might want to add a test case for "user types same title after error" to verify expected behavior.

4. Missing Test: Request Cancellation

While AbortSignal is tested in MediaWikiAPIs.ts:1468-1498, there's no integration test verifying that TanStack Query actually cancels in-flight requests when input changes rapidly.

Recommendation: Consider adding a test in PageTitleInput.tsx that:

  1. Types "Test"
  2. Waits for fetch to start (but not complete)
  3. Types "New Title" immediately
  4. Verifies the first request was cancelled
    This would provide confidence in the full cancellation flow.

5. DevTools in Production

App.tsx:501 includes <ReactQueryDevtools initialIsOpen={false} /> which is fine (it's tree-shaken in prod builds), but explicitly wrapping in import.meta.env.DEV would make this clearer:

{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}

This is a minor documentation improvement to make the intent explicit.

6. CSS Button Styling Specificity

The new CSS rules (App.css:410-426) use .status-message-container button selector. This might have unintended side effects if other buttons are added to status messages later.

Recommendation: Consider using a more specific class:

.status-message-container .retry-button { /* ... */ }

And add className="retry-button" to the button element.


🔒 Security Considerations

No security concerns identified:

  • AbortSignal properly prevents memory leaks from abandoned requests
  • Error messages don't leak sensitive information
  • No XSS vulnerabilities (React escapes all output)
  • CSRF isn't applicable (read-only API calls)

📊 Performance Considerations

Performance improvements:

  • Request cancellation prevents wasted network traffic
  • Caching reduces redundant API calls
  • Debouncing (300ms) reduces API load during typing

⚠️ Minor consideration:

  • The 1-hour staleTime is excellent for page IDs, but ensure this is documented (users won't see new pages created in the last hour)
  • Consider shorter staleTime during development for better DX

🧪 Test Coverage Analysis

Excellent coverage:

  • All components have comprehensive test suites
  • Edge cases (404, network errors, empty input) covered
  • Retry behavior verified
  • Debouncing tested
  • All tests passing in CI

Potential additions (optional):

  • Integration test for request cancellation flow
  • Test for stale data behavior (verify cache returns cached value)

📝 Documentation Quality

Outstanding documentation:

  • ADR-004 is comprehensive and well-structured
  • CLAUDE.md updated with clear TanStack Query patterns
  • Code comments are minimal but appropriate (self-documenting code)
  • PR description is thorough with clear trade-offs

Minor suggestion: Add a comment in PageIdFetcher.tsx:531 explaining why empty title returns null (avoids Suspense flash).


🎯 Final Recommendation

✅ APPROVE - Ready to Merge

This is a high-quality PR that:

  • Solves a real problem (React anti-pattern)
  • Implements the solution correctly
  • Has comprehensive tests
  • Includes excellent documentation
  • Follows project conventions
  • All CI checks passing

The observations above are minor improvements that could be addressed in follow-up PRs if desired, but none are blockers.

Suggested follow-ups (optional, not blocking):

  1. Add integration test for request cancellation
  2. Reconcile bundle size numbers between PR description and ADR
  3. Add conditional DevTools rendering for clarity
  4. Consider more specific CSS class for retry button

Great work on this migration! 🎉

@Wintus
Copy link
Owner Author

Wintus commented Feb 5, 2026

The 1-hour staleTime is excellent for page IDs, but ensure this is documented (users won't see new pages created in the last hour)

This app is a history reader, so such a freshy new page won't be a problem with a short revision history.

@Wintus Wintus merged commit 10b88a9 into trunk Feb 5, 2026
4 checks passed
@Wintus Wintus deleted the feat/tanstack-query-migration branch February 5, 2026 13:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Migrate PageTitleInput to TanStack Query with useSuspenseQuery

1 participant