Our current data-fetching pattern has several maintainability issues:
- Verbose and abused
useEffect blocks — every component that needs data from ExtensionRegistryService must manually convert Promises into state variables (isLoading, error, data). This leads to useEffect being overused as a general-purpose async mechanism. The result is components that are hard to read, hard to test, and prone to side-effect bugs.
- Manual cancellation —
AbortController signals need to be manually threaded through each call, which is fragile and difficult to debug when something goes wrong.
- Unsafe retry behavior —
fetch-retry wraps all requests including write mutations, which get silently retried up to 10 times on failure. This is dangerous for non-idempotent operations and invisible to the user.
- No path to reliable testing — because data-fetching is tightly coupled to the
ExtensionRegistryService with no provider abstraction, there is no clean way to mock network behavior in unit or integration tests. TanStack Query's QueryClientProvider is the industry standard solution to this: in production the app uses a real client, and in tests you swap it for a mock one, enabling both hook-level and full component tree testing without any real network calls.
Proposed solution
Adopt TanStack Query (@tanstack/react-query) as the standard data-fetching layer. It handles loading/error state, caching, request deduplication, automatic cancellation, and retry logic out of the box — with sensible defaults (reads retry, mutations do not).
As a future improvement, we could consider leveraging React Error Boundaries alongside TanStack Query's, which lets fetch failures propagate as render errors and be caught at a boundary rather than handled per-component. This opens the door to consistent, centralized error UI without any additional per-hook wiring.
Components should not call useQuery / useMutation directly. We should wrap each data-fetching point in a custom hook (e.g. useCustomers(), useCreateTier(), useExtensionDetail(ns, name)) that owns the query key, the queryFn, enabled conditions, staleTime, transforms, and any invalidateQueries on success. Components will only render and call the hook — no service access, no query keys, no abort controllers. Hooks should be the only place that imports ExtensionRegistryService.
I've created a POC replacing the search query with that #1763
Here's a list of all the components that will be affected by this refactor, grouped by phases, from less traffic to higher:
Phase 1 — Admin
Phase 2 — User settings
Phase 3 — Public-facing
Our current data-fetching pattern has several maintainability issues:
useEffectblocks — every component that needs data fromExtensionRegistryServicemust manually convert Promises into state variables (isLoading,error,data). This leads touseEffectbeing overused as a general-purpose async mechanism. The result is components that are hard to read, hard to test, and prone to side-effect bugs.AbortControllersignals need to be manually threaded through each call, which is fragile and difficult to debug when something goes wrong.fetch-retrywraps all requests including write mutations, which get silently retried up to 10 times on failure. This is dangerous for non-idempotent operations and invisible to the user.ExtensionRegistryServicewith no provider abstraction, there is no clean way to mock network behavior in unit or integration tests. TanStack Query'sQueryClientProvideris the industry standard solution to this: in production the app uses a real client, and in tests you swap it for a mock one, enabling both hook-level and full component tree testing without any real network calls.Proposed solution
Adopt TanStack Query (
@tanstack/react-query) as the standard data-fetching layer. It handles loading/error state, caching, request deduplication, automatic cancellation, and retry logic out of the box — with sensible defaults (reads retry, mutations do not).As a future improvement, we could consider leveraging React Error Boundaries alongside TanStack Query's, which lets fetch failures propagate as render errors and be caught at a boundary rather than handled per-component. This opens the door to consistent, centralized error UI without any additional per-hook wiring.
Components should not call
useQuery/useMutationdirectly. We should wrap each data-fetching point in a custom hook (e.g.useCustomers(),useCreateTier(),useExtensionDetail(ns, name)) that owns the query key, thequeryFn,enabledconditions,staleTime, transforms, and anyinvalidateQuerieson success. Components will only render and call the hook — noserviceaccess, no query keys, no abort controllers. Hooks should be the only place that importsExtensionRegistryService.I've created a POC replacing the search query with that #1763
Here's a list of all the components that will be affected by this refactor, grouped by phases, from less traffic to higher:
Phase 1 — Admin
src/pages/admin-dashboard/settings.tsxsrc/pages/admin-dashboard/logs/logs.tsxsrc/pages/admin-dashboard/tiers/tiers.tsxsrc/pages/admin-dashboard/tiers/tier-form-dialog.tsxsrc/pages/admin-dashboard/customers/customers.tsxsrc/pages/admin-dashboard/customers/customer-details.tsxsrc/pages/admin-dashboard/customers/customer-token-list.tsxsrc/pages/admin-dashboard/customers/customer-form-dialog.tsxsrc/pages/admin-dashboard/customers/customer-member-list.tsxsrc/pages/admin-dashboard/usage-stats/usage-stats.tsxsrc/pages/admin-dashboard/usage-stats/use-usage-stats.tssrc/components/rate-limiting/usage-stats/use-usage-stats.tssrc/pages/admin-dashboard/namespace-admin.tsxsrc/pages/admin-dashboard/namespace-change-dialog.tsxsrc/pages/admin-dashboard/namespace-delete-dialog.tsxsrc/pages/admin-dashboard/publisher-admin.tsxsrc/pages/admin-dashboard/publisher-revoke-dialog.tsxsrc/pages/admin-dashboard/publisher-revoke-tokens-button.tsxsrc/pages/admin-dashboard/extension-admin.tsxsrc/pages/admin-dashboard/extension-remove-dialog.tsxsrc/pages/admin-dashboard/extension-version-container.tsxscan-context.tsxand only replace the network hooks, dispatching fromonSuccess/onErrorsrc/context/scan-admin/scan-api-effects.tssrc/context/scan-admin/scan-api-actions.tssrc/pages/admin-dashboard/scan-admin.tsxPhase 2 — User settings
src/pages/user/user-settings-tokens.tsxsrc/pages/user/generate-access-token-dialog.tsxsrc/components/generate-token-dialog.tsxsrc/pages/user/user-settings-namespaces.tsxsrc/pages/user/create-namespace-dialog.tsxsrc/pages/user/user-namespace-details.tsxsrc/pages/user/user-namespace-member-list.tsxsrc/pages/user/user-namespace-member-component.tsxsrc/pages/user/add-namespace-member-dialog.tsxsrc/pages/user/user-settings-extensions.tsxsrc/pages/user/user-namespace-extension-list.tsxsrc/pages/user/user-namespace-extension-list-item.tsxsrc/pages/user/user-settings-delete-extension.tsxsrc/pages/user/publish-extension-dialog.tsxsrc/pages/user/user-publisher-agreement.tsxsrc/pages/user/user-settings-customers.tsxPhase 3 — Public-facing
MainContextshape;userwould read fromuseQuery(['user'])andupdateUserwould callinvalidateQueriessrc/main.tsxsrc/pages/extension-list/extension-list.tsx(→useInfiniteQuery)src/pages/extension-list/extension-list-item.tsxsrc/pages/namespace-detail/namespace-detail.tsxsrc/pages/extension-detail/use-extension-details.tsxsrc/pages/extension-detail/extension-detail.tsxsrc/pages/extension-detail/extension-detail-overview.tsxsrc/pages/extension-detail/extension-detail-changes.tsxsrc/pages/extension-detail/extension-detail-reviews.tsxsrc/pages/extension-detail/extension-review-dialog.tsx