calypso-react-query-migration

star 12.6k

Use when editing or migrating Calypso Reader data-fetching code — `client/components/data/query-reader-*` components, `@automattic/data-stores` Reader hooks, or new Reader queries/mutations. Triggers on any work involving `api-core`, `api-queries`, `dispatchRequest`, `READER_*_REQUEST` actions, or Redux `isRequesting*` selectors in the Reader.

Automattic By Automattic schedule Updated 5/1/2026

name: calypso-react-query-migration description: Use when editing or migrating Calypso Reader data-fetching code — client/components/data/query-reader-* components, @automattic/data-stores Reader hooks, or new Reader queries/mutations. Triggers on any work involving api-core, api-queries, dispatchRequest, READER_*_REQUEST actions, or Redux isRequesting* selectors in the Reader.

Calypso React Query Migration

Target architecture: fetchers in @automattic/api-corequery/mutation options in @automattic/api-queriescomponents call useQuery() / useMutation() directly.

Two source patterns migrate into it:

  1. Redux data-layer (dispatchRequest + http actions in client/state/data-layer/wpcom/read/)
  2. @automattic/data-stores Reader hooks (custom hooks using wpcomRequest)

Quick Reference: Where Things Go

What Where
API fetcher packages/api-core/src/read-{name}/fetchers.ts
Mutators packages/api-core/src/read-{name}/mutators.ts
Response types packages/api-core/src/read-{name}/types.ts
Barrel packages/api-core/src/read-{name}/index.ts + add to packages/api-core/src/index.ts
Query/mutation options packages/api-queries/src/read-{name}.ts + add to packages/api-queries/src/index.ts
Bridge component (if kept) client/components/data/query-reader-{name}/index.tsx
Component tests client/components/data/query-reader-{name}/test/index.test.tsx
Redux to remove client/state/data-layer/wpcom/read/{name}/index.js, client/state/reader/{name}/{actions,reducer}.{ts,js}, client/state/reader/action-types.ts

Naming conventions (must follow)

Every Reader fetcher / query / mutation / response type carries the Read prefix — the rest of api-core and api-queries does this consistently and breaking the pattern stands out in code review.

Kind Pattern Examples
Fetcher fetchRead{Name} fetchReadFeed, fetchReadSubscriptionDetails
Query factory read{Name}Query readFeedQuery, readSubscriptionDetailsQuery
Mutation factory {verb}Read{Name}Mutation addReadListFeedMutation, unfollowReadTagMutation
Response types Read{Name}Response, Read{Name}ErrorResponse ReadFeedSearchResponse, ReadSubscriptionDetailsResponse
Args/params types Read{Name}Args, FetchRead{Name}Params ReadSubscriptionDetailsArgs

Workflow (Required)

digraph migration_flow {
    "Migration request" [shape=doublecircle];
    "Audit CRUD + existing fetchers" [shape=box];
    "Write plan" [shape=box];
    "Bridge decision" [shape=diamond];
    "Plan: remove bridge + adapt consumers" [shape=box];
    "Plan: HOC for class component" [shape=box];
    "Plan: keep bridge (record reason)" [shape=box];
    "Execute plan with checkpoints" [shape=box];

    "Migration request" -> "Audit CRUD + existing fetchers";
    "Audit CRUD + existing fetchers" -> "Bridge decision";
    "Bridge decision" -> "Plan: remove bridge + adapt consumers" [label="few function-component consumers"];
    "Bridge decision" -> "Plan: HOC for class component" [label="class component is main consumer"];
    "Bridge decision" -> "Plan: keep bridge (record reason)" [label="many/cross-cutting consumers"];
    "Plan: remove bridge + adapt consumers" -> "Write plan";
    "Plan: HOC for class component" -> "Write plan";
    "Plan: keep bridge (record reason)" -> "Write plan";
    "Write plan" -> "Execute plan with checkpoints";
}

Always plan before editing. A one-line request like "migrate QueryReaderTag" is a spec — turn it into a plan first.

REQUIRED SUB-SKILLS:

  • superpowers:writing-plans — to draft the plan
  • superpowers:executing-plans — to execute it with checkpoints

The plan must include: full CRUD audit, bridge decision (with reason), commit split, per-mutation invalidations + optimistic-update decision, list of consumers to update, test plan.

Save the plan to .context/plan-migrate-reader-{name}.md.

Step 1: Audit before writing

CRUD coverage

List every action for the resource — read AND mutations. Partial migrations create two sources of truth (deleted item still shows in sidebar until refresh).

grep -E "READER_[A-Z_]*" client/state/data-layer/wpcom/read/{name}/index.js
grep -E "READER_[A-Z_]*" client/state/reader/{name}/actions.ts
Operation Redux pattern Target
Read READER_XXX_REQUESTdispatchRequest GET useQuery(readXxxQuery(...))
Create / Update / Delete READER_XXX_*dispatchRequest POST useMutation(...Mutation())
Follow / Unfollow READER_XXX_FOLLOW etc. dedicated mutation each

Plan one commit per action group; each commit removes its Redux counterpart (mutations don't need a bridge).

Existing api-core fetchers

Don't duplicate. Grep before creating:

grep -rn "/read/{endpoint}" packages/api-core/src/
ls packages/api-core/src/ | grep -i {name}

If a fetcher exists: reuse (import from @automattic/api-core) or extend (add to existing folder, not a parallel one).

Cache-key consumers (mandatory check)

Before changing or replacing a query key, find every other piece of code reading or writing it. Mutations and shared helpers commonly do optimistic setQueryData / invalidateQueries against the old key — change the key without updating them and they silently no-op, leaving the user looking at stale data after every action.

# Find every reference to the existing key.
grep -rn "['\"]{old-key-segment}['\"]" packages/ client/ --include="*.ts" --include="*.tsx"

# Also check the shared helpers, which often centralize optimistic logic.
grep -rn "alter{Name}\|invalidate{Name}" packages/data-stores/src/reader/helpers/

For every match, decide:

  • Update to the new key (preferred — small follow-up patch in the same PR, or co-located mutation migration).
  • Migrate the consumer too (when it's a mutation that should join this migration anyway).
  • Bridge (last resort: keep the old key alive in parallel until the consumer is migrated).

Document the decision per match in the plan. The trap: when the only consumer of the old key is a mutation's optimistic write, deleting the data-stores hook looks clean — grep for the hook name finds nothing — but the mutation still operates on a phantom cache that no live query reads, and the visible page never updates after the user acts.

Step 2: Bridge decision

The QueryReader* bridge dispatches RECEIVE so the rest of Calypso keeps reading from Redux. It's not the long-term target. Evaluate removing it in this migration before defaulting to keeping it.

# Render sites
grep -rn "QueryReader{Name}" client/ --include="*.{ts,tsx,js,jsx}"
# Indirect Redux consumers
grep -rn "get{Name}\|isRequesting{Name}" client/ --include="*.{ts,tsx,js,jsx}"
Audit result Recommended decision
1–3 function-component consumers, slice not read elsewhere Remove bridge. Adapt consumers to useQuery. Delete RECEIVE action, reducer, selectors.
Class component is the main consumer Wrap class with HOC that calls useQuery() and forwards props. Delete bridge + slice. (See redux-cleanup.md for the HOC pattern.)
Many consumers, or selectors used cross-codebase, or cross-Reader code reads slice Keep bridge. Record reason in plan; note as follow-up.

Record the decision verbatim in the plan. When removing the bridge, grep to confirm nothing else reads RECEIVE/reducer/selectors before deleting them.

Step 3: Build the migration

Create the fetcher (api-core)

// packages/api-core/src/read-{name}/fetchers.ts
import { addQueryArgs } from '@wordpress/url';
import { wpcom } from '../wpcom-fetcher';
import type { ReadXxxResponse } from './types';

export const fetchReadXxx = ( params?: SomeParams ): Promise< ReadXxxResponse > => {
  return wpcom.req.get( {
    path: addQueryArgs( '/read/endpoint', { key: 'value' } ),
    apiVersion: '1.2',
  } );
};

Find method, path, apiVersion, and query/body in the existing data-layer http() call. Copy exactly. Always import wpcom from ../wpcom-fetcher (not directly).

Add types in types.ts, barrel-export from index.ts, then export the module from packages/api-core/src/index.ts.

Subkey-auth fallback (logged-out subscription-management endpoints)

Some Reader endpoints (/read/sites/{blogId}/subscription-details, /read/subscriptions/{id}, anything reachable from the public /subscriptions/... landing pages) accept logged-out callers via an X-WPSUBKEY header. The legacy data-stores callApi helper handles this; wpcom.req.get does not, because wpcom-proxy-request requires a session.

Trigger: the source hook calls callApi with an isLoggedIn arg, or imports getSubkey, or the consumer renders under a /subscriptions/... route.

Before designing the fork, confirm with backend:

  • Does the endpoint accept both cookie auth and X-WPSUBKEY?
  • If both arrive, which wins? (Calypso ships the cookie unconditionally for logged-in users, so the answer affects whether the subkey path is safe to take preemptively.)
  • When is window.currentUser.subscriptionManagementSubkey populated? (Today: only the logged-out subscriptions bootstrap.)

Pattern when both auth modes are accepted and cookie wins server-side — the bifurcation becomes pure transport, not URL or auth strategy:

const getSubkey = (): string | undefined =>
  ( window as typeof window & { currentUser?: { subscriptionManagementSubkey?: string } } )
    .currentUser?.subscriptionManagementSubkey;

export const fetchReadXxx = async ( params ): Promise< ReadXxxResponse< string > > => {
  const path = `/read/...`;
  const subkey = getSubkey();

  if ( subkey ) {
    const response = await fetch( `https://public-api.wordpress.com/wpcom/v2${ path }`, {
      method: 'GET',
      credentials: 'same-origin',
      headers: {
        Authorization: `X-WPSUBKEY ${ encodeURIComponent( subkey ) }`,
        'Content-Type': 'application/json',
      },
    } );
    return response.json();
  }

  return wpcom.req.get( { path, apiNamespace: 'wpcom/v2', apiVersion: '2' } );
};

Use raw fetch rather than @wordpress/api-fetch — avoids adding the dep to api-core and the subkey path needs none of apiFetch's middleware. The client/lib/request-with-subkey-fallback/ helper does the same thing for non-Reader code, but don't import it from api-core (wrong direction); inline the logic.

Create the query options (api-queries)

// packages/api-queries/src/read-{name}.ts
import { fetchReadXxx } from '@automattic/api-core';
import { queryOptions } from '@tanstack/react-query';

export const readXxxQuery = ( param?: string | null ) =>
  queryOptions( {
    queryKey: [ 'read', 'xxx', param ],
    staleTime: 1000 * 60 * 5,
    queryFn: () => fetchReadXxx( param! ),
    enabled: param != null,
  } );

queryKey: ['read', '{domain}', ...params]. staleTime: ~1min for data with external change events (payments, renewals, server-side mutations), ~5min for slowly-changing lists. Confirm change-rate with backend when unsure. enabled: when params can be null.

When migrating from data-stores, preserve the same queryKey for cache compatibility mid-session.

Bridge component (only if kept)

// client/components/data/query-reader-{name}/index.tsx
import { readXxxQuery } from '@automattic/api-queries';
import { useQuery } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { receiveXxx } from 'calypso/state/reader/xxx/actions';

export default function QueryReaderXxx() {
  const dispatch = useDispatch();
  const { data } = useQuery( readXxxQuery() );

  useEffect( () => {
    if ( data?.items ) dispatch( receiveXxx( data.items ) );
  }, [ data, dispatch ] );

  return null;
}

If the legacy code dispatched success/failure actions, mirror both via isSuccess / isError effects.

Tests

See test-scaffolding.md for the full template (createTestStore, renderWithProviders, nock setup).

nock URL: https://public-api.wordpress.com/rest/v{apiVersion}/{path} for wpcom.req.get calls, https://public-api.wordpress.com/wpcom/v2{path} for apiNamespace: 'wpcom/v2' v2 calls. Run with yarn test-client client/components/data/query-reader-{name}/test/.

Mutations

See mutations.md — covers mutators, mutationOptions, cache invalidation rules, optimistic updates (default for user-facing mutations), and where side effects live (consumer's onSuccess, not api-queries).

Migrating from data-stores

Same fetcher/query pattern, with two differences:

  • No bridge needed — the consumer already uses React Query; just swap the import.
  • Single commit — no Redux cleanup.
// Before
import { Reader } from '@automattic/data-stores';
const { data, isFetching } = Reader.useReadFeedSiteQuery( siteId );

// After
import { readFeedSiteQuery } from '@automattic/api-queries';
import { useQuery } from '@tanstack/react-query';
const { data, isFetching } = useQuery( readFeedSiteQuery( siteId ) );

After updating consumers, delete the hook from packages/data-stores/src/reader/queries/ and remove its export from the Reader barrel.

Step 4: Clean up Redux

Read redux-cleanup.md for: per-action checklist, replacing isRequesting* selectors with React Query state, function-component connect() → hooks conversion, and the class-component HOC pattern.

Cleanup is a separate commit from the migration commit — easier to review and revert.

Red Flags — STOP

If you catch yourself doing any of these, stop and reconsider:

  • About to edit api-core, api-queries, client/components/data/query-reader-*, or Redux Reader state without a written plan → write the plan first.
  • About to migrate only the read query while leaving CREATE/UPDATE/DELETE in Redux → finish the CRUD audit first.
  • About to create packages/api-core/src/read-{name}/ without grepping for an existing module → check api-core first.
  • About to delete an isRequesting* selector or RECEIVE reducer without grepping for consumers → grep first.
  • About to write a mutation with only invalidateQueries and no onMutate for a user-facing action → consider optimistic updates.
  • About to put navigation / notice dispatch inside the mutation's onSuccess in api-queries → side effects belong in the consumer's onSuccess.
  • About to rewrite a class component to a function component as part of this migration → out of scope; use an HOC instead.
  • About to change the queryKey when migrating from data-stores → preserve it for mid-session cache compatibility.
  • About to delete or rename a query key without grepping for every consumer (mutations, optimistic-update helpers, selectors, other queries that prefix-invalidate it) → grep first; mutations referencing a now-orphan key silently no-op and the visible page stops updating after user actions.

Common Mistakes

Mistake Fix
Forgetting to dispatch RECEIVE from the bridge Other code still reads from Redux — bridge is required when kept
Wrong nock URL (missing /rest/v{N}/) Always https://public-api.wordpress.com/rest/v{apiVersion}/{path}
Removing unrelated handlers from data-layer file Surgical removal — only the specific REQUEST handler
Missing enabled when query has required params Query fires with undefined and fails
Cleanup in same commit as migration Keep separate for reviewability
.jsx for a new file Always .tsx
Forgetting barrel exports Update api-core/src/index.ts AND api-queries/src/index.ts
Importing wpcom directly Always from ../wpcom-fetcher
invalidateQueries for a deleted item's detail query Use removeQueries; invalidateQueries for list
Forgetting cancelQueries before optimistic write An in-flight refetch can overwrite your optimistic value
Skipping onSettled invalidation in optimistic mutation Optimistic value drifts from server truth
Leaving connect() HOC after request state moved to React Query Convert to hooks, remove HOC entirely (function components only)
Leaving the old data-stores hook exported Delete file AND remove from Reader barrel
Naming the fetcher / query without the Read prefix Match other modules: fetchRead{Name}, read{Name}Query, Read{Name}Response
Importing client/lib/request-with-subkey-fallback/ from api-core Wrong dependency direction — inline the subkey + X-WPSUBKEY logic in the fetcher
Install via CLI
npx skills add https://github.com/Automattic/wp-calypso --skill calypso-react-query-migration
Repository Details
star Stars 12,636
call_split Forks 2,012
navigation Branch main
article Path SKILL.md
More from Creator