refactor: convert course recommendations to React Query - #1967
Open
brian-smith-tcril wants to merge 1 commit into
Open
refactor: convert course recommendations to React Query#1967brian-smith-tcril wants to merge 1 commit into
brian-smith-tcril wants to merge 1 commit into
Conversation
brian-smith-tcril
marked this pull request as ready for review
July 31, 2026 19:37
3 tasks
Replace the Redux `recommendations` slice + thunk with a `useCourseRecommendations` React Query hook, as the pattern-setter for the wider Redux -> React Query migration (#1946). - Add the app-level QueryClient/QueryClientProvider (bare, matching the frontend-base shell) and a QueryClientProvider wrapper in the test render. - Add a colocated data layer: data/queryKeys.ts (rooted at a new `appId` constant) + data/apiHooks.ts (useQuery over the existing getCourseRecommendations). - Delete data/slice.js and data/thunks.js; CourseExit now calls postUnsubscribeFromGoalReminders directly from data/api.js. - Remove the `recommendations` reducer from the store and the test store. - CourseRecommendations branches on React Query flags (isPending/isError/ isSuccess) instead of a status string; the tracking event moves to a colocated track.js. Behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
brian-smith-tcril
force-pushed
the
bsmith/react-query-course-recommendations
branch
from
July 31, 2026 19:41
10eb2bd to
1604f36
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #1967 +/- ##
==========================================
+ Coverage 91.48% 91.55% +0.06%
==========================================
Files 353 354 +1
Lines 5838 5824 -14
Branches 1356 1391 +35
==========================================
- Hits 5341 5332 -9
+ Misses 478 473 -5
Partials 19 19 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Converts the course-exit course recommendations feature from Redux to React Query. This is the pattern-setter PR for the wider Redux → React Query migration tracked in #1946 (per OEP-0067 ADR-0010), following the same path
frontend-app-authnandfrontend-app-learner-dashboardtook.Behavior is unchanged — recommendations still fetch from discovery and render the same table (or the catalog-suggestion fallback for < 2 results / errors).
What changed
@tanstack/react-query; an app-levelQueryClient/QueryClientProviderinsrc/index.jsx(bare defaults, matching the frontend-base shell's client); and aQueryClientProviderin the shared testrender().course-exit/data/queryKeys.ts(query-key factory rooted at a newappIdconstant insrc/constants.ts) +course-exit/data/apiHooks.ts(useCourseRecommendations, auseQueryover the existinggetCourseRecommendations).course-exit/data/slice.js(therecommendationsslice) anddata/thunks.js; remove therecommendationsreducer fromsrc/store.tsand the test store.CourseExitnow callspostUnsubscribeFromGoalRemindersdirectly fromdata/api.js.CourseRecommendationsbranches on React Query flags (isPending/isError/isSuccess) instead of a Redux status string; thesendTrackEventcall moves to a colocatedtrack.js.Decisions
Full decision log
Scope
course-exitcourse recommendations. This PR stands up theReact Query plumbing and converts the smallest genuinely Redux-backed read
end-to-end, as the reference the wider Redux → React Query effort (Convert Learning from redux to Context + react-query #1946) copies.
We deliberately did not use an api-only leaf (
preferences-unsubscribe,celebration,enrollment-alert) — those use no Redux, so converting themwouldn't demonstrate the actual removal. Recommendations is one
recommendationsslice + one thunk + a single consumer (
CourseRecommendations.jsx) that readsdata →
useQuery.Dependency
@tanstack/react-query@^5.90.19as a directdependency. Major v5 matchesthe reference apps (authn
^5.90.19, LD^5.90.16) and frontend-base's peerrequirement (
^5.81.2); resolved to5.101.4. Adependency(notpeerDependency) because the app bundles its own deps.React Query client config
App-level client is bare:
new QueryClient()(nostaleTime/retryoptions). Matches the frontend-base shell's own bare client (
shell/site.tsx:new QueryClient()). We rejected copying the reference apps' values becauseneither is principled and they disagree:
staleTime: 5 * 60_000. Traced to PR chore(i18n): update translations #786: it originally shippedstaleTime: 60 * 60_000(1h); reviewer (arbrandes) pushed back ("quite alarge stale time … default
gcTimeis 5 min, so it's kind of pointless …what's the intended behavior?") and it was reduced to 5 min in a "chore: minor
improvements" commit — review-nudged, not a real product decision.
retry: false, but for an auth-specific reason (arbrandes: "afailed login retried 3 times would be confusing") that doesn't apply to
reading recommendations.
Test client (
createTestQueryClientinsetupTest.js):retry: falseonqueries + mutations; no
gcTime.retry: falseis universal across thereferences' test setups (authn
createWrapper, LD test wrapper, and the shell'sown test files) — without it a failing query retries 3× with backoff, slowing
tests and flaking error-path assertions. We dropped
gcTime: 0(LD's testwrapper sets it, but authn and the shell tests don't):
render()creates afresh client per call, so cache isolation is already guaranteed. The
bare-app-client / configured-test-client split mirrors the shell (bare
site.tsxclient, configured test files).QueryClientProviderplacement. Nested just insideAppProvider(
AppProvider > QueryClientProvider > …) in bothsrc/index.jsxand the sharedtest
render(). Matches LD's ordering;AppProviderstays the outermost appwrapper.
Query keys
appIdconstant. Keys live ineach feature's
data/queryKeys.ts(herecourse-exit/data/queryKeys.ts), rootedat
appIdfromsrc/constants.ts([appId, '<feature>', …]). We chose thisover LD's fully-centralized
src/data/data layer:data/dirs), so colocation matches the codebase and keeps each conversion PR local
and low-conflict; a central keys file would be a hot shared file and a large
upfront structural move.
appIdconstant (authn's discipline —import { appId } from '../../constants') gives collision-safety and aconsistent namespace without the centralization churn.
src/data/later if cross-feature invalidationactually becomes common (it rarely does).
export const appId = 'learning';tosrc/constants.ts(value matchesthe legacy
APP_IDenv var).Consumer conversion (
CourseRecommendations.jsx)isPending/isError/isSuccess),not a derived legacy status string. Matches the reference repos, which use RQ
flags in components (e.g. LD
Dashboard:const { data, isPending } = …;MasqueradeBar:!isError && !isPending). An earlier draft derived arecommendationsStatus(LOADING/LOADED/FAILED) to keep the old branchconditions; dropped as un-idiomatic. Behavior is identical
(
LOADING→isPending,FAILED→isError,LOADED→isSuccess).track.js(feature root), followingauthn's
src/recommendations/track.jspattern:trackRecommendationsViewed({ courseKey, isError, length })owns thesendTrackEventcall and theFAILED/LOADEDstatus-string mapping — the one place the legacy status stringsare still needed, purely for analytics continuity (the event reports the same
recommendations_statusvalues as before). Consequence:CourseRecommendations.jsxno longer importssendTrackEventor@src/constants. Chose this over LD's heavier centralsrc/tracking/+useCourseTrackingEventmachinery — overkill for one event.courseIdstill read from thecoursewareRedux slice viauseSelector—that slice isn't part of this conversion, and reading a not-yet-converted slice
is expected during the incremental effort. Dropped
useDispatch, the fetchuseEffect, anduseModel('coursewareMeta').recommendations(the query owns thedata now).
recommendations.lengthdirectly (dropped therecommendationsLengthlocal and the old
recommendations ? … : 0guard): the query'sdata = []default guarantees an array, so the guard was dead code.
Data layer
data/slice.js(therecommendationsslice) and removed its reducerfrom
store.tsandsetupTest.js'sinitializeTestStore.data/thunks.jsentirely. After removing the recommendations thunk,its only remaining export was
unsubscribeFromGoalReminders— a redundantwrapper around
postUnsubscribeFromGoalReminders(already inapi.js), andCourseExit.jsxonly passedcourseId. SoCourseExit.jsxnow callspostUnsubscribeFromGoalRemindersdirectly fromdata/api.js. The unsubscribetest asserts on the POST, so behavior is unchanged.
data/api.jsgetCourseRecommendationsunchanged as thequeryFn(it fetches recommendations + enrollments and filters). Pact/api tests keep
testing it.
Out of scope
useQuery). TheuseMutationpattern and the rest of learning's Redux surface are convertedseparately under the wider effort tracked in Convert Learning from redux to Context + react-query #1946.
Test plan
npm run lint,npm run types,npm run build— clean.npm test— full suite green (100 suites / 868 passed / 3 skipped), includingCourseExit.test.jsx's recommendations coverage: success→table (with already-enrolled / same-course filtering), and< 2/error→catalog-suggestion fallback.course_recommendationsresponse through the converteduseQuery; also verified the fallback path when discovery returns< 2/ errors.Refs #1946
🤖 Generated with Claude Code