Agent Skills: Redux + Saga to SWR Migration Guide

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.

UncategorizedID: cockroachdb/cockroach/redux-to-swr

Repository

cockroachdbLicense: NOASSERTION
32,2654,065

Install this agent skill to your local

pnpm dlx add-skill https://github.com/cockroachdb/cockroach/tree/HEAD/pkg/ui/.claude/skills/redux-to-swr

Skill Files

Browse the full folder contents for redux-to-swr.

Download Skill

Loading file tree…

pkg/ui/.claude/skills/redux-to-swr/SKILL.md

Skill Metadata

Name
redux-to-swr
Description
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.

Redux + Saga to SWR Migration Guide

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.


Architecture

Before (Redux + Saga)

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 export
  • store/<feature>/<feature>.reducer.ts — Redux reducer
  • store/<feature>/<feature>.sagas.ts — saga watchers/workers
  • store/<feature>/<feature>.sagas.spec.ts — saga tests
  • store/<feature>/<feature>.selectors.ts — selectors
  • store/reducers.ts — registers slice in combineReducers
  • store/sagas.ts — forks the saga
  • <component>Connected.tsx — connect() HOC
  • db-console wrapper with connect() / mapStateToProps / mapDispatchToProps

In 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.

After (SWR)

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 directly
  • db-console wrapper simplified to a thin functional component or removed entirely

Step-by-Step Migration

1. Create the SWR Hook (in api/<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 data
  • dedupingInterval — how long to dedup identical requests (default 2s)
  • Use 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:

  • Simple string for singleton resources: "getInsightRecommendations"
  • Object/array for parameterized resources: { name: "schedule", id: idStr }
  • Export the key constant if other hooks need to share the cache: export const NODES_SWR_KEY = "nodesUI"
  • SWR supports arrays/objects as keys natively — JSON.stringify is usually unnecessary

Simplify 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.

2. Convert the Component to Use Hooks Directly

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:

  • Replace mapStateToProps selectors with direct hook calls
  • Replace useEffect(() => refresh(), []) dispatch with SWR's automatic fetching
  • Replace Redux-stored sort/filter state with useState + URL sync via useEffect + syncHistory (read initial state from URL params on mount, sync changes back to URL)
  • Use the <Loading> component with isLoading and error from SWR
  • Call hooks and contexts directly instead of threading props from connected wrappers — e.g. useContext(ClusterDetailsContext) for tenant info, useUserSQLRoles() for roles.
  • Check cluster-ui/src/api/ and cluster-ui/src/util/ for existing hooks before creating new props or wrappers.
  • Don't use useMemo for trivial computations — wrapping parseInt(show, 10) or simple string operations adds overhead without benefit.

3. Simplify the Connected/Wrapper Component

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.

4. Delete Redux Artifacts

Remove these files/registrations:

  • store/<feature>/index.ts
  • store/<feature>/<feature>.reducer.ts
  • store/<feature>/<feature>.sagas.ts
  • store/<feature>/<feature>.sagas.spec.ts
  • store/<feature>/<feature>.selectors.ts
  • Remove the slice from store/reducers.ts (combineReducers)
  • Remove the fork() from store/sagas.ts
  • Remove the Redux state type from AdminUiState if in db-console
  • Remove db-console redux.ts / redux.spec.ts wrapper files
  • Delete storybook files if they only existed to provide Redux context

For db-console CachedDataReducer features, also remove:

  • The CachedDataReducer / PaginatedCachedDataReducer instance from apiReducers.ts
  • The exported refresh* function
  • The field from APIReducersState
  • The API function from util/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.

5. Write Tests

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:

  1. Data loaded — verify content renders
  2. Loading state — verify loading spinner (getByTestId("loading-spinner"))
  3. Error state — verify error message renders
  4. Role-based visibility — test with different roles (use table-driven tests for 3+ roles)
  5. Route params — use <MemoryRouter initialEntries> + <Route path> for param-dependent components

Testing 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.

6. Run Lint Fix

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

Composing Hooks

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} ... />;

Extracting Pure Functions from Selectors

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.


Cache Invalidation After Mutations

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:

  • Destructure mutate from the read hook and alias it (e.g. mutate: refreshFiles, mutate: refreshTables)
  • Use useSwrMutationWithClusterId (from src/util) for write operations — it wraps useSWRMutation with cluster ID
  • Call the aliased mutate after the write succeeds to trigger a revalidation
  • Don't use SWR's global mutate — prefer the bound mutate from the hook that owns the cache key

See jobProfilerView.tsx and getTableMetadataApi.ts for real examples.


Gradual Migration: SWR-to-Redux Bridge

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.


PR Structure

  • Split large migrations into separate commits/PRs:
    1. Add SWR hooks (API layer)
    2. Convert component to use hooks
    3. Simplify connected/wrapper components
    4. Delete Redux store files
  • PR description should list what Redux functionality was removed vs preserved
  • Note removed functionality explicitly (e.g. "removed analytics.track — team agreed low value")

Request Batching (Advanced)

For components where many instances render simultaneously (e.g. metrics graphs), implement a batch fetcher module:

  • Queue requests via requestBatched(), group by shared parameters
  • Flush after the current render cycle via setTimeout(0) (not queueMicrotask — SWR v2's useLayoutEffect callbacks fire across separate microtask windows)
  • Split unified responses back to individual callers by query count offset
  • Expose resetBatchState() for test cleanup