Migrate React components from Redux + Saga to SWR hooks. Use when converting data fetching from Redux store (reducers, sagas, selectors, connect HOC) to SWR-based hooks in CockroachDB DB Console or cluster-ui.
Migrate React components from Redux + redux-saga data fetching to SWR (stale-while-revalidate) hooks. The migration eliminates boilerplate (reducers, sagas, selectors, action creators, connected components) and replaces it with co-located data fetching hooks.
Component (connected via mapStateToProps/mapDispatchToProps)
-> dispatch(refresh()) action
-> saga watches for action, calls API
-> saga dispatches success/failure actions
-> reducer updates store
-> selector reads from store
-> mapStateToProps feeds data to component
Files involved per feature:
store/<feature>/index.ts — barrel exportstore/<feature>/<feature>.reducer.ts — Redux reducerstore/<feature>/<feature>.sagas.ts — saga watchers/workersstore/<feature>/<feature>.sagas.spec.tsstore/<feature>/<feature>.selectors.ts — selectorsstore/reducers.ts — registers slice in combineReducersstore/sagas.ts — forks the saga<component>Connected.tsx — connect() HOCIn db-console, many features use CachedDataReducer or PaginatedCachedDataReducer rather than explicit saga files. These reducers internally handle async data fetching via dispatched actions and register in apiReducers.ts, exporting refresh* functions. The migration approach is the same.
Component calls useMyData() hook
-> hook calls useSwrWithClusterId(key, fetcher, options)
-> SWR handles caching, deduplication, revalidation
-> component reads { data, error, isLoading } directly
Files involved per feature:
api/<feature>Api.ts — API function + SWR hook (co-located)<component>View.tsx — functional component using hooks directlyapi/<feature>Api.ts)Add a hook next to the existing API fetcher function. Use useSwrWithClusterId from src/util for cluster-ui components.
// Pattern: Simple hook wrapping an existing fetcher
export function useSchemaInsights() {
return useSwrWithClusterId<SqlApiResponse<InsightRecommendation[]>>(
"getInsightRecommendations", // stable string key
() => getSchemaInsights(), // existing fetcher function
{
revalidateOnFocus: false,
dedupingInterval: 10_000,
},
);
}
// Pattern: Hook with conditional fetching and caller-configurable polling.
// Use null fetcher to skip fetching (e.g. for tenants that lack the endpoint).
export const useNodes = (opts?: { refreshInterval?: number }) => {
const { isTenant } = useContext(ClusterDetailsContext);
const { data, isLoading, error } = useSwrWithClusterId(
NODES_SWR_KEY,
!isTenant ? getNodes : null, // null fetcher = skip for tenants
{
revalidateOnFocus: false,
dedupingInterval: 10_000,
refreshInterval: opts?.refreshInterval, // caller opts in to polling
},
);
return { nodeStatuses: data?.nodes ?? [], isLoading, error };
};
// Usage — overview page polls every 10s, detail pages don't:
const { nodeStatuses } = useNodes({ refreshInterval: 10_000 });
// Pattern: Conditional fetching based on parameter availability.
// Use null key when the key itself depends on a value that may not be available yet.
export const useNodeLogs = (nodeId: string) => {
const shouldFetch = Boolean(nodeId);
return useSwrWithClusterId(
shouldFetch ? { name: "nodeLogs", nodeId } : null, // null key = don't fetch
() => getNodeLogs(nodeId),
{ revalidateOnFocus: false },
);
};
// Pattern: Hook with dynamic key (e.g. detail pages)
const { data, error, isLoading } = useSwrWithClusterId(
{ name: "schedule", id: idStr }, // object key, varies per entity
() => getSchedule(Long.fromString(idStr)),
);
Key decisions for SWR configuration:
refreshInterval — for data that should poll (dashboards, live lists). Accept as an option so callers can opt in.revalidateOnFocus: false — for expensive or rarely-changing datadedupingInterval — how long to dedup identical requests (default 2s)useSWRImmutable / useSwrImmutableWithClusterId when data should be fetched once and never automatically revalidated (no revalidation on stale, focus, or reconnect). If another hook mutates the same cache key, the updated value is still reflected — "immutable" refers to the revalidation policy, not the data itself.SWR key design:
"getInsightRecommendations"{ name: "schedule", id: idStr }export const NODES_SWR_KEY = "nodesUI"JSON.stringify is usually unnecessarySimplify types when removing Redux indirection. If a parameter was string | (() => string) because the saga needed a thunk, simplify to just string now that the hook calls the fetcher directly.
Don't over-abstract. If a hook is only used by one component, it's fine to keep it simple. Don't create unnecessary wrapper hooks.
Never mutate SWR cached data. SWR's cache is shared — mutating a cached object (e.g. using reduce into ns.metrics directly) causes bugs like double-counting. Always spread into a fresh object: { ...ns.metrics }.
When a hook combines multiple data sources, see Composing Hooks. When extracting computation from Redux selectors, see Extracting Pure Functions.
Replace the connected component pattern with direct hook calls.
Before:
interface StateProps {
data: InsightRecommendation[];
isLoading: boolean;
error: Error;
}
interface DispatchProps {
refresh: () => void;
}
type Props = StateProps & DispatchProps;
const SchemaInsightsView: React.FC<Props> = (props) => {
useEffect(() => { props.refresh(); }, []);
// uses props.data, props.isLoading, props.error
};
export default connect(mapStateToProps, mapDispatchToProps)(SchemaInsightsView);
After:
export const SchemaInsightsView: React.FC = () => {
const { data, error, isLoading } = useSchemaInsights();
const { data: roles } = useUserSQLRoles();
// ... all state is local via useState
// ... URL sync via useHistory + useEffect
return ( /* JSX */ );
};
Key patterns:
mapStateToProps selectors with direct hook callsuseEffect(() => refresh(), []) dispatch with SWR's automatic fetchinguseState + URL sync via useEffect + syncHistory (read initial state from URL params on mount, sync changes back to URL)<Loading> component with isLoading and error from SWRuseContext(ClusterDetailsContext) for tenant info, useUserSQLRoles() for roles.cluster-ui/src/api/ and cluster-ui/src/util/ for existing hooks before creating new props or wrappers.useMemo for trivial computations — wrapping parseInt(show, 10) or simple string operations adds overhead without benefit.Prefer hooks over HOCs. Replace withRouter(connect(...)(Component)) with useParams, useHistory, useSelector, useDispatch inside the component. Note: useSelector/useDispatch are only for state that genuinely remains in Redux (e.g. timeScale, global UI preferences) — data fetching should use SWR hooks.
cluster-ui connected component — simplify to re-export or minimal wrapper:
// Before: 90+ lines of mapStateToProps, mapDispatchToProps, connect()
// After: Simple re-export or thin wrapper
export const ConnectedIndexDetailsPage: React.FC = () => {
const { database, table, index } = useParams<RouteParams>();
const breadcrumbPrefix = useSelector(selectClusterPrefix);
return (
<IndexDetailsPage
databaseName={database}
tableName={table}
indexName={index}
breadcrumbPrefix={breadcrumbPrefix}
/>
);
};
db-console wrapper — use hooks, not HOCs:
// Before: connect(mapStateToProps, mapDispatchToProps)(withRouter(Component))
// After: functional component with hooks
import { IndexDetailsPageComponent } from "@cockroachlabs/cluster-ui";
export const IndexDetailsPage: React.FC = () => {
const { database, table, index } = useParams<RouteParams>();
const timeScale = useSelector(selectTimeScale);
const dispatch = useDispatch();
return (
<IndexDetailsPageComponent
databaseName={database}
tableName={table}
indexName={index}
timeScale={timeScale}
onTimeScaleChange={(ts) => dispatch(setGlobalTimeScaleAction(ts))}
/>
);
};
If the db-console wrapper has no db-console-specific logic (no useSelector, no useDispatch), it can be a simple re-export:
export { ScheduleDetails as default } from "@cockroachlabs/cluster-ui";
Don't use useCallback unnecessarily in connected components — wrapping Redux dispatch calls or simple functions in useCallback adds noise. If a function is only used as a useEffect dependency, inline the logic in the effect instead.
Remove these files/registrations:
store/<feature>/index.tsstore/<feature>/<feature>.reducer.tsstore/<feature>/<feature>.sagas.tsstore/<feature>/<feature>.sagas.spec.tsstore/<feature>/<feature>.selectors.tsstore/reducers.ts (combineReducers)fork() from store/sagas.tsAdminUiState if in db-consoleredux.ts / redux.spec.ts wrapper filesFor db-console CachedDataReducer features, also remove:
CachedDataReducer / PaginatedCachedDataReducer instance from apiReducers.tsrefresh* functionAPIReducersStateutil/api.ts (if only used by the reducer)Before removing the db-console API function, ensure a replacement fetcher exists in cluster-ui/src/api/<feature>Api.ts. cluster-ui does not import from db-console — it defines its own fetchers using fetchData from src/api. Create the cluster-ui fetcher + SWR hook first (Step 1), then remove the db-console artifacts.
Check for stale selectors. When removing Redux data fetching, verify that selectors still referencing the old Redux state are also removed or replaced. A selector reading from a store slice that is no longer populated will silently return undefined.
Check db-console re-exports. When removing a connected component, check if db-console re-exports it and simplify the db-console side too.
Always mock the full SWR return shape including mutate and isValidating:
{ data, isLoading, error, mutate: jest.fn(), isValidating: false }
Verify mock names match actual exports. A test mocking useNodeStatuses when the export was renamed to useNodes will silently pass but test nothing.
Preferred pattern: jest.spyOn the hook module
import * as schemaInsightsApi from "../../api/schemaInsightsApi";
it("renders expected data", () => {
const spy = jest.spyOn(schemaInsightsApi, "useSchemaInsights")
.mockReturnValue({
data: { results: fixtureData, maxSizeReached: false },
isLoading: false,
error: null,
mutate: jest.fn(),
isValidating: false,
});
const { getByText } = render(
<MemoryRouter>
<SchemaInsightsView />
</MemoryRouter>,
);
// assertions...
spy.mockClear();
});
Alternative: jest.mock at module level
jest.mock("../api/nodesApi", () => ({
useNodes: () => ({
nodeStatuses: [],
isLoading: false,
error: null,
}),
}));
Test cases to cover:
getByTestId("loading-spinner"))<MemoryRouter initialEntries> + <Route path> for param-dependent componentsTesting hooks directly with renderHook:
import { renderHook } from "@testing-library/react-hooks";
import * as nodesApi from "../api/nodesApi";
it.each([
{ nodesLoading: true, expected: true },
{ nodesLoading: false, expected: false },
])("loading state (%o)", ({ nodesLoading, expected }) => {
jest.spyOn(nodesApi, "useNodes").mockReturnValue(
{ nodeStatuses: [], isLoading: nodesLoading, error: undefined },
);
const { result } = renderHook(() => useNodesSummary());
expect(result.current.isLoading).toBe(expected);
});
db-console test mocking — mock at the @cockroachlabs/cluster-ui module level:
jest.mock("@cockroachlabs/cluster-ui", () => ({
...jest.requireActual("@cockroachlabs/cluster-ui"),
useNodesSummary: jest.fn(),
}));
const mockUseNodesSummary = useNodesSummary as jest.Mock;
mockUseNodesSummary.mockReturnValue({
nodeStatusByID: { "1": mockNode },
isLoading: false,
error: undefined,
});
Wrap components in <MemoryRouter> since hooks like useHistory and useParams require router context. For parameterized routes:
<MemoryRouter initialEntries={["/schedules/123"]}>
<Route path="/schedules/:id">
<ScheduleDetails />
</Route>
</MemoryRouter>
Testing pitfall: When testing "non-admin" behavior, use a realistic non-admin role (e.g. ["VIEWACTIVITY"]) rather than an empty array [], since empty roles is an edge case that may not reflect real usage. Use table-driven tests when testing multiple role combinations.
After all code changes are complete, run lint:fix in both workspaces to auto-fix any lint issues introduced during the migration:
cd pkg/ui/workspaces/cluster-ui && pnpm run lint:fix
cd pkg/ui/workspaces/db-console && pnpm run lint:fix
When a component needs data from multiple sources, compose hooks. Don't include isLoading in useMemo dependencies — it causes unnecessary recomputation. Derive loading state outside the memo.
// Hook that combines two other hooks
export const useNodesSummary = () => {
const { nodeStatuses, isLoading: nodesLoading, error: nodesError } = useNodes();
const { livenesses, statuses, isLoading: livenessLoading, error: livenessError } = useLiveness();
const isLoading = nodesLoading || livenessLoading;
const summary = useMemo(() => {
// derive combined data
}, [nodeStatuses, livenesses, statuses]); // NOT isLoading
return {
...summary,
isLoading,
error: nodesError ?? livenessError,
};
};
SWR automatically deduplicates — if useNodes() is called from both useNodesSummary() and another component, only one API call is made (controlled by dedupingInterval).
Error aggregation — when a component uses multiple hooks, combine errors:
// For composed hooks returning a single error:
return { error: nodesError ?? livenessError };
// For components rendering a <Loading> with multiple error sources:
const errors = [nodesError, ddError, livenessError].filter(Boolean);
return <Loading loading={isLoading} error={errors} ... />;
When Redux selectors contain computation logic (not just state access), extract it into pure exported functions. These are independently testable and composable with useMemo:
// Before: computation buried in a createSelector
export const selectClusterName = createSelector(
nodeStatusesSelector,
livenessSelector,
(nodeStatuses, liveness) => { /* compute cluster name */ }
);
// After: pure function in cluster-ui, composed with useMemo
// In api/clusterApi.ts:
export function getClusterName(
nodeStatuses: INodeStatus[],
livenessStatusByNodeID: Record<string, number>,
): string | undefined {
// Pure computation — no Redux, no hooks
}
// In hook or component:
const clusterName = useMemo(
() => getClusterName(nodeStatuses, statuses),
[nodeStatuses, statuses],
);
This enables sharing logic between cluster-ui and db-console, and makes computation testable without mocking hooks or Redux.
When a component performs a write operation (POST, DELETE, PUT) and needs to refresh cached data, use the bound mutate from the read hook combined with useSwrMutationWithClusterId for the write:
// Read hook returns bound mutate for manual cache refresh
const { data: detailFiles, mutate: refreshFiles } = useSwrWithClusterId(
{ name: "jobProfilerExecutionFiles", jobID },
() => listExecutionDetailFiles({ job_id: jobID }),
{ refreshInterval: 10_000 },
);
// Write hook triggers the mutation, then refreshes the read cache
const { trigger } = useSwrMutationWithClusterId(
{ name: "collectExecutionDetails", jobID },
async () => {
const resp = await collectExecutionDetails({ job_id: jobID });
if (resp.req_resp) {
refreshFiles(); // invalidate the read cache after successful write
}
},
);
Key patterns:
mutate from the read hook and alias it (e.g. mutate: refreshFiles, mutate: refreshTables)useSwrMutationWithClusterId (from src/util) for write operations — it wraps useSWRMutation with cluster IDmutate after the write succeeds to trigger a revalidationmutate — prefer the bound mutate from the hook that owns the cache keySee jobProfilerView.tsx and getTableMetadataApi.ts for real examples.
When existing Redux selectors or alert banners still depend on data that has been migrated to SWR, use a bridge component to sync SWR state into Redux. This avoids rewriting all consumers at once.
// Minimal Redux slice to hold the SWR-sourced value
export function healthReducer(state = { lastError: null }, action) {
switch (action.type) {
case SET_HEALTH_ERROR:
return { lastError: action.error };
default:
return state;
}
}
// Invisible bridge component — mounts in the app layout
export function HealthMonitor(): null {
const { error } = useHealth();
const dispatch = useDispatch();
useEffect(() => {
dispatch(setHealthError(error ?? null));
}, [error, dispatch]);
return null; // renders nothing
}
// In layout:
<HealthMonitor />
<AlertBanner /> {/* still reads from Redux */}
Use this pattern sparingly — it's a transitional step. Once all consumers are migrated, remove the bridge and the Redux slice.
For components where many instances render simultaneously (e.g. metrics graphs), implement a batch fetcher module:
requestBatched(), group by shared parameterssetTimeout(0) (not queueMicrotask — SWR v2's useLayoutEffect callbacks fire across separate microtask windows)resetBatchState() for test cleanup