MIGRATIONS.md Recipes: Six Concrete Stack-Pair Migrations
Part 2 said write a MIGRATIONS.md. This post is six concrete recipes — one per common stack pair — that any agent can execute end-to-end.
Part 2 of this series introduced MIGRATIONS.md — a one-page document with three rows per concept: ✅ Current, ❄️ Frozen, 🎯 Target. That document tells the agent what state things are in.
This post is what to write when you actually want to do the migration — six recipes, one per stack pair I’ve seen most often across audits. Each recipe is a complete, agent-ready document. Drop it in docs/prompts/migrate-<X>-to-<Y>.md, hand it to Claude Code or Cursor along with a scope (“migrate the Customers slice”), and it should run end-to-end.
The six pairs:
- Redux Toolkit → Zustand
- Sass / SCSS Modules → Tailwind v4
- MUI v5 → MUI v6
- Formik → react-hook-form + Zod
useEffect-fetch → TanStack Query- Class components → Function components + hooks
Each recipe follows the same five-section shape: Scope, Reference implementation, Step-by-step migration, Gotchas, Verification checklist. That’s the same skeleton from Part 5 (“task prompts that work first try”) — these are task prompts. The migration is the task.
One framing note before the recipes: a MIGRATIONS.md entry says “Frozen: Redux Toolkit under src/legacy-store/.” A recipe says “here is how to move one slice off it.” The doc is the map. The recipe is the move. You need both.
Recipe 1: Redux Toolkit → Zustand
Scope
Migrate one Redux Toolkit slice to one Zustand store. One slice at a time. Do not migrate the Provider, do not migrate sibling slices, do not delete the legacy store directory.
Files in scope:
src/legacy-store/slices/<feature>Slice.ts— read, then delete after migration.- All call sites:
useSelector((s) => s.<feature>.X)anduseDispatch()for<feature>actions.
Files not in scope:
src/legacy-store/store.ts(theconfigureStorecall) — leave it.<App>root with<Provider store={store}>— leave it.- Other slices — they continue working through the Provider.
Reference implementation
src/stores/customerStore.ts— canonical Zustand store with selectors, devtools, andpersist.src/stores/__tests__/customerStore.test.ts— the testing pattern (store reset between tests).
Read both before writing code.
Step-by-step migration
Step 1. Create src/stores/<feature>Store.ts. Copy the shape of the reference.
Step 2. Translate the slice’s initialState to the Zustand state interface.
❌ Before — slices/customerSlice.ts:
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface CustomerState {
items: Customer[];
selectedId: string | null;
filter: string;
}
const initialState: CustomerState = {
items: [],
selectedId: null,
filter: "",
};
const customerSlice = createSlice({
name: "customer",
initialState,
reducers: {
setItems(state, action: PayloadAction<Customer[]>) {
state.items = action.payload;
},
selectCustomer(state, action: PayloadAction<string>) {
state.selectedId = action.payload;
},
setFilter(state, action: PayloadAction<string>) {
state.filter = action.payload;
},
},
});
export const { setItems, selectCustomer, setFilter } = customerSlice.actions;
export default customerSlice.reducer;✅ After — stores/customerStore.ts:
import { create } from "zustand";
import { devtools } from "zustand/middleware";
interface CustomerState {
items: Customer[];
selectedId: string | null;
filter: string;
setItems: (items: Customer[]) => void;
selectCustomer: (id: string) => void;
setFilter: (filter: string) => void;
}
export const useCustomerStore = create<CustomerState>()(
devtools(
(set) => ({
items: [],
selectedId: null,
filter: "",
setItems: (items) => set({ items }),
selectCustomer: (selectedId) => set({ selectedId }),
setFilter: (filter) => set({ filter }),
}),
{ name: "customer" },
),
);Step 3. Translate every useSelector call site to a Zustand selector hook.
❌ Before:
const items = useSelector((s: RootState) => s.customer.items);
const filter = useSelector((s: RootState) => s.customer.filter);
const dispatch = useDispatch();
dispatch(setFilter("acme"));✅ After:
const items = useCustomerStore((s) => s.items);
const filter = useCustomerStore((s) => s.filter);
const setFilter = useCustomerStore((s) => s.setFilter);
setFilter("acme");One selector per piece of state. Never useCustomerStore((s) => ({ items: s.items, filter: s.filter })) without shallow — that re-renders on every store change.
Step 4. Translate derived selectors. createSelector from Reselect becomes derived state computed inside the selector hook, or a separate useMemo at the call site.
❌ Before — selectors/customerSelectors.ts:
import { createSelector } from "@reduxjs/toolkit";
export const selectFilteredCustomers = createSelector(
[(s: RootState) => s.customer.items, (s: RootState) => s.customer.filter],
(items, filter) =>
items.filter((c) => c.name.toLowerCase().includes(filter.toLowerCase())),
);✅ After — colocated with the call site:
const filtered = useCustomerStore((s) =>
s.items.filter((c) => c.name.toLowerCase().includes(s.filter.toLowerCase())),
);If the derivation is expensive, use a custom hook with useMemo and a shallow-compared selector. Don’t reach for zustand/middleware/shallow until you’ve measured.
Step 5. Delete the slice file and its imports from the root reducer. Run typecheck — every stale reference fails loudly.
Step 6. Update MIGRATIONS.md. The <feature> row moves from a Frozen footnote to migrated history.
Gotchas
⚠ The agent will migrate the Provider. It will not. The Provider stays as long as one slice still lives under it. Tell the agent explicitly: “Do not edit <App> root, do not touch src/legacy-store/store.ts.”
⚠ Derived selectors silently lose memoization. createSelector caches by reference equality. A plain Zustand selector recomputes every render. For lists where filtering is cheap this is fine. For expensive derivations, wrap in useMemo with dependencies, or build a selector with zustand/middleware/shallow.
⚠ Middleware APIs differ. Redux Toolkit’s redux-persist config doesn’t translate one-to-one. Zustand’s persist middleware takes { name, storage, partialize }. The agent will paste the persist config; review it by hand — partialize is where you whitelist what gets persisted, and the default behaviour persists everything.
⚠ Action payloads aren’t free-form. Redux actions can take arbitrary payloads; Zustand setters are typed functions on the state interface. If a reducer did multiple state writes, port them into a single setter that takes a structured argument — not three separate setters called in sequence.
⚠ Thunks have no Zustand equivalent. If a slice has createAsyncThunk, that’s almost always server state. Move it to TanStack Query (Recipe 5), not to Zustand. Zustand stores should hold client state only.
From the audits I’ve done, the most common mistake is migrating the whole legacy-store/ directory in one PR. Don’t. One slice per PR. The Provider stays. The typecheck catches every stale useSelector.
Verification checklist
-
src/stores/<feature>Store.tsexists and exportsuse<Feature>Store. - No
useSelector/useDispatchfor<feature>remains. (grep -rE "s\.<feature>\." srcreturns zero.) -
src/legacy-store/slices/<feature>Slice.tsdeleted. - Root reducer no longer imports the deleted slice.
- Devtools shows the store under the configured
name. -
pnpm typecheckpasses with zero errors. -
pnpm testpasses; per-test store reset is in place if any test mutates the store. -
MIGRATIONS.mdupdated.
Recipe 2: Sass / SCSS Modules → Tailwind v4
Scope
Migrate one component (and its colocated .module.scss) at a time. Strategy is rewrite, not auto-translate: convert the JSX with Tailwind utilities, then delete the .module.scss file. Do not run a codemod that maps SCSS rules to @apply directives — you will end up with a Tailwind config that is just SCSS with extra steps.
Files in scope:
- One
.tsxcomponent and its sibling.module.scss.
Not in scope:
src/styles/_legacy/*.scssglobal partials — those move only after every consumer is migrated.- Mixins exported across files — leave the mixin file alive until the last consumer is gone.
Reference implementation
src/components/marketing/HeroBanner.tsx— a component recently migrated from a.module.scss. Look at the JSX, the variant prop, and thecvarecipe.src/styles/tokens.css— the source of truth for every colour and spacing token.
Step-by-step migration
Step 1. Open the .module.scss. Inventory: how many top-level selectors, how many state variants (:hover, &.active), how many media queries, how many SCSS variables.
Step 2. For each SCSS variable, find or add the matching CSS custom property in tokens.css under @theme.
❌ Before — Banner.module.scss:
$banner-bg: #0b3d91;
$banner-fg: #ffffff;
$banner-radius: 12px;
.banner {
background: $banner-bg;
color: $banner-fg;
border-radius: $banner-radius;
padding: 24px 32px;
}✅ After — tokens.css:
@theme {
--color-banner-bg: oklch(35% 0.15 260);
--color-banner-fg: oklch(100% 0 0);
--radius-banner: 0.75rem;
}And Banner.tsx:
<div className="rounded-[--radius-banner] bg-[--color-banner-bg] text-[--color-banner-fg] px-8 py-6">
{children}
</div>Step 3. Translate state variants to Tailwind state utilities.
❌ Before:
.button {
background: var(--accent-500);
&:hover { background: var(--accent-600); }
&.active { background: var(--accent-700); }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}✅ After:
<button
data-active={isActive || undefined}
className="
bg-accent-500
hover:bg-accent-600
data-[active]:bg-accent-700
disabled:opacity-50 disabled:cursor-not-allowed
"
/>The data-active attribute pattern is how you bind a runtime boolean to a CSS state selector without resorting to conditional class strings.
Step 4. Translate SCSS mixins. There are two cases.
Local mixin used once or twice — inline it as utilities. The mixin disappears.
Cross-file mixin used many times — convert to a cva recipe in the component or a @utility in Tailwind v4. Acknowledged: @apply exists and works. It is also a smell. Reach for it only when (a) the rule is genuinely cross-component and (b) cva would mean prop-drilling a variant through six layers.
// component-level recipe via cva
import { cva } from "class-variance-authority";
export const card = cva(
"rounded-lg border border-line bg-surface shadow-sm",
{
variants: {
tone: {
neutral: "bg-surface",
accent: "bg-accent-50",
danger: "bg-danger-50 border-danger-200",
},
size: { sm: "p-3", md: "p-5", lg: "p-8" },
},
defaultVariants: { tone: "neutral", size: "md" },
},
);Step 5. Translate :global selectors. SCSS Modules use :global(.foo) to escape the local hash; Tailwind has no equivalent because there’s no hashing in the first place. Two replacements:
- If the global was a one-off override, move it into
globals.csswrapped in:where()so it stays low-specificity. - If the global was a hack to style a child component, fix the child component instead. The hack disappears.
Step 6. Delete the .module.scss file. Remove the import styles from "./X.module.scss" line. Remove the className={styles.x} references — they should already be gone, but a final pass catches stragglers.
Gotchas
⚠ Don’t translate SCSS rule-by-rule. The agent’s instinct is to map one SCSS block to one Tailwind class string. That preserves SCSS organisation in Tailwind, which is the worst of both worlds. Tell the agent: “Rewrite the JSX layout-first; ignore the SCSS structure.”
⚠ Variable name collisions. SCSS lets you have $primary in two files meaning different things. Tokens are global. Pick a namespaced name (--color-banner-bg) or risk colliding with an existing token.
⚠ Mixins that take parameters. SCSS mixins with arguments (@mixin elevation($level)) translate to either a cva variant or a small React component. They do not translate to @apply.
⚠ :global migrations are easy to over-do. When in doubt, ask whether the global selector is fixing a styling issue or a structure issue. Most are structure issues in disguise.
⚠ Don’t migrate a component that’s about to be redesigned. Wasted work. If the design system roadmap has this page slated for a v2 in the next quarter, defer.
From the audits I’ve done, the most common mistake is keeping the .module.scss file “for safety” after migrating the JSX. It now silently bloats the bundle and confuses the next agent that opens the directory. Delete the file in the same commit.
Verification checklist
-
.module.scssdeleted. - No
import styles from "./*.module.scss"remains in the component file. - Component renders identically — visual diff (Playwright screenshot or Chromatic) shows zero pixel changes.
- All new tokens added to
tokens.cssunder@theme, not inlined as hex. - No
@applyused outsideglobals.cssand the documented recipes file. -
pnpm buildproduces a smaller CSS bundle (stat dist/_astro/*.css). - No new
:globalselectors introduced. -
MIGRATIONS.mdrow for this component flipped from Frozen to Current.
Recipe 3: MUI v5 → MUI v6
Scope
Bump @mui/material from v5 to v6, then fix the breaking changes one component family at a time. One PR per family — Grid, Theme, Palette, sx — not one PR for the whole upgrade.
In scope:
package.jsonversion bump.- All
<Grid>and<Grid2>usages. - Theme file (
src/theme/index.ts). - Components that use deprecated palette tokens.
Not in scope on the first pass:
- Pigment CSS adoption. v6 supports it as an opt-in zero-runtime CSS engine; treat it as a separate migration that comes after the v6 baseline is green.
Reference implementation
- The official MUI v5 → v6 codemod output for one already-migrated file —
src/components/billing/InvoiceTable.tsx. src/theme/index.ts— the v6 theme shape with the newcolorSchemesAPI.
Step-by-step migration
Step 1. Bump the version, run the codemod:
pnpm dlx @mui/codemod@latest v6.0.0/preset-safe srcThe codemod handles ~80% of the mechanical changes. Review the diff before committing — it occasionally rewrites comments. Commit the codemod output as its own commit so the next steps are reviewable separately.
Step 2. Migrate Grid2 → Grid. In v6, the legacy Grid is removed and the v5-era Grid2 (@mui/material/Unstable_Grid2) becomes the new Grid.
❌ Before:
import Grid2 from "@mui/material/Unstable_Grid2";
<Grid2 container spacing={2}>
<Grid2 xs={12} md={6}>...</Grid2>
</Grid2>✅ After:
import Grid from "@mui/material/Grid";
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 6 }}>...</Grid>
</Grid>Note the API shift: xs={12} md={6} becomes size={{ xs: 12, md: 6 }}. The codemod handles this for Grid2. It does not handle it for the legacy Grid — the agent has to rewrite those by hand.
Step 3. Update the theme. v6 introduces colorSchemes for native light/dark support.
❌ Before — v5:
import { createTheme } from "@mui/material/styles";
export const theme = createTheme({
palette: {
mode: "light",
primary: { main: "#0b3d91" },
secondary: { main: "#f59e0b" },
},
});✅ After — v6:
import { createTheme } from "@mui/material/styles";
export const theme = createTheme({
colorSchemes: {
light: {
palette: {
primary: { main: "#0b3d91" },
secondary: { main: "#f59e0b" },
},
},
dark: {
palette: {
primary: { main: "#7aa9ff" },
secondary: { main: "#fcd34d" },
},
},
},
});Step 4. Fix deprecated palette tokens. v6 removed several v5-era aliases (palette.primary.lightChannel, palette.text.primaryChannel, and friends). Run typecheck — every removed token is now a TS error.
Step 5. Audit sx usage. v6 changed the type narrowing for sx so that array-of-callbacks expressions that compiled silently in v5 now produce TS errors. Convert array callbacks:
❌ Before:
<Box sx={[{ p: 2 }, (theme) => ({ color: theme.palette.text.primary })]} />✅ After:
<Box
sx={(theme) => ({
p: 2,
color: theme.palette.text.primary,
})}
/>Step 6. Pigment CSS is not in scope. If MIGRATIONS.md lists Pigment as a target, add a separate migrate-mui-to-pigment.md recipe. Do not mix the two.
Gotchas
⚠ The codemod is partial. It handles Grid2 → Grid imports and a few palette token renames. It does not handle the Grid size prop shift or the colorSchemes theme restructure. Plan for manual work.
⚠ useTheme() return type narrowed. Any code that did theme.palette.foo as string may now type-check correctly and reveal a latent bug. Take the type errors seriously — they are usually pointing at real problems.
⚠ StyledEngineProvider semantics changed slightly. If your app uses both Emotion (default) and @emotion/styled consumers, double-check the cache injection order after the upgrade. The visible symptom is “MUI styles override our custom styles in production but not in dev.”
⚠ Date pickers track a separate version. @mui/x-date-pickers v7 is the matching peer for MUI v6. Bump them together or the date picker imports break.
⚠ Storybook decorators. If your Storybook uses the v5 ThemeProvider, update the decorator to the v6 shape. Otherwise stories render with the default theme and you’ll think your migration broke colours.
From the audits I’ve done, the most common mistake is migrating Grid in one PR and theme in another separate week, leaving the codebase half-migrated for days. Do them in the same week, behind the same feature branch off main, even if they’re separate PRs.
Verification checklist
-
@mui/materialand@mui/x-date-pickersversions are aligned to v6 / v7. - No imports from
@mui/material/Unstable_Grid2. - No
<Grid xs={N}>— all usesize={{ xs: N }}. - Theme uses
colorSchemes(light + dark) — even if dark mode is “off” today. -
pnpm typecheckpasses; no@ts-ignoreadded. - Visual regression: storybook builds, every story renders, no missing palette tokens.
-
MIGRATIONS.mdrow updated; Pigment migration listed separately as 🎯 Target.
Recipe 4: Formik → react-hook-form + Zod
Scope
Migrate one Formik form at a time. A “form” means one top-level <Formik> and its tree.
In scope:
- The form component, its Yup schema, and its submit handler.
- Custom field components used only by this form.
Not in scope:
- Field components shared across many forms (
molecules/FormTextField, etc.) — those should already exist in their react-hook-form shape from Part 2’s Week 2. If they don’t, write them first.
Reference implementation
src/forms/CustomerForm.tsx— canonical RHF form withuseForm,Controller, andzodResolver.src/forms/schemas/customer.ts— the matching Zod schema.
Step-by-step migration
Step 1. Translate the Yup schema to Zod. Most translations are one-for-one.
| Yup | Zod |
|---|---|
yup.string().required() | z.string().min(1, "Required") |
yup.string().email() | z.string().email() |
yup.number().positive() | z.number().positive() |
yup.array().of(...).min(1) | z.array(...).min(1) |
yup.object().shape({ ... }) | z.object({ ... }) |
yup.string().nullable() | z.string().nullable() |
.when("field", ...) | .refine() or .superRefine() |
❌ Before — schemas/invoiceYup.ts:
import * as yup from "yup";
export const invoiceSchema = yup.object({
customerId: yup.string().required("Customer required"),
amount: yup.number().positive().required(),
notes: yup.string().nullable(),
});✅ After — schemas/invoice.ts:
import { z } from "zod";
export const invoiceSchema = z.object({
customerId: z.string().min(1, "Customer required"),
amount: z.number().positive(),
notes: z.string().nullable(),
});
export type InvoiceInput = z.infer<typeof invoiceSchema>;Step 2. Replace the Formik root with useForm.
❌ Before:
<Formik
initialValues={defaults}
validationSchema={invoiceSchema}
onSubmit={handleSubmit}
>
{(formik) => (
<Form>
<Field name="customerId" />
{formik.errors.customerId && <span>{formik.errors.customerId}</span>}
</Form>
)}
</Formik>✅ After:
const form = useForm<InvoiceInput>({
defaultValues: defaults,
resolver: zodResolver(invoiceSchema),
});
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)}>
<input {...form.register("customerId")} />
{form.formState.errors.customerId && (
<span>{form.formState.errors.customerId.message}</span>
)}
</form>
</FormProvider>
);Step 3. Translate <Field> calls.
For native inputs, use register:
<input {...form.register("amount", { valueAsNumber: true })} />For custom or controlled components (MUI Autocomplete, date pickers, anything that doesn’t accept ref natively), use <Controller>:
<Controller
control={form.control}
name="customerId"
render={({ field, fieldState }) => (
<CustomerAutocomplete
value={field.value}
onChange={field.onChange}
error={fieldState.error?.message}
/>
)}
/>Step 4. Translate FieldArray to useFieldArray.
❌ Before:
<FieldArray name="lineItems">
{({ push, remove }) => (
values.lineItems.map((_, i) => (
<div key={i}>
<Field name={`lineItems.${i}.description`} />
<button onClick={() => remove(i)}>Remove</button>
</div>
))
)}
</FieldArray>✅ After:
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "lineItems",
});
return fields.map((field, i) => (
<div key={field.id}>
<input {...form.register(`lineItems.${i}.description`)} />
<button type="button" onClick={() => remove(i)}>Remove</button>
</div>
));Note key={field.id} — react-hook-form provides a stable id on each field; do not use the array index.
Step 5. Delete the Formik component file and any formik-mui bridge components.
Gotchas
⚠ Error display location differs. Formik exposes formik.errors.fieldName; RHF exposes formState.errors.fieldName.message. The agent will write errors.fieldName (without .message) and silently render [object Object] in production.
⚠ touched vs dirty semantics. Formik’s touched flag flips when a field is blurred. RHF’s touchedFields flips on blur, dirtyFields flips on change. If your form previously only showed errors after blur, set mode: "onTouched" in useForm.
⚠ Form-level errors. Formik supports setStatus({ formError }) for whole-form messages. In RHF, use setError("root", { message }) and read formState.errors.root?.message.
⚠ Initial values vs default values. Formik re-initialises from initialValues when they change (with enableReinitialize). RHF does not re-render on defaultValues change — you have to call form.reset(newDefaults) in a useEffect.
⚠ Async validation. Yup’s async validation maps to Zod via .refine(async (val) => ...) returning a promise. The Zod resolver supports this, but the agent will sometimes forget the async keyword in the refinement.
From the audits I’ve done, the most common mistake is keeping formik-mui field bridges (<FormikTextField>) around “until later.” They block the migration of every field component. Replace them in the same PR as the form root.
Verification checklist
- No
Formik,Field,Form,FieldArray, oruseFormikimports remain in the file. - No
formikorformik-muioryupimports remain in the file. - Zod schema exported with
z.infertype alias. - Form errors render
.message, not the error object. - Form
modechosen explicitly (onSubmit,onBlur,onTouched,onChange). - If form had
enableReinitialize, replacementuseEffect+resetis in place. -
pnpm typecheckandpnpm testpass. -
MIGRATIONS.mdrow for forms updated.
Recipe 5: useEffect-fetch → TanStack Query
Scope
Migrate one screen at a time. A screen typically has 1–3 data-loading hooks. Migrate all of them together — partial migration leaves you with two loading-state shapes on the same page.
In scope:
useEffect(() => { fetch(...).then(setData) }, [...])patterns.- Sibling
useStatefordata,loading,error. - Mutations done with raw
fetch+ manual refetch.
Not in scope:
- WebSocket subscriptions — TanStack Query has
streamedQuerybut it’s a separate recipe. - File uploads — keep
useMutationfor the kickoff but the upload mechanism itself is unchanged.
Reference implementation
src/screens/Invoices/InvoicesList.tsx— canonicaluseQueryusage with pagination.src/api/queries.ts— query-key factory pattern (this is the convention; copy it).
Step-by-step migration
Step 1. Replace the effect with useQuery.
❌ Before:
const [invoices, setInvoices] = useState<Invoice[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setLoading(true);
fetch("/api/invoices")
.then((r) => r.json())
.then(setInvoices)
.catch(setError)
.finally(() => setLoading(false));
}, []);✅ After:
const { data: invoices = [], isLoading, error } = useQuery({
queryKey: invoiceKeys.list(),
queryFn: () => fetch("/api/invoices").then((r) => r.json()),
});Three useStates collapse into one hook. isLoading is true only on first load; subsequent refetches set isFetching instead — which is usually what you want (no full-page spinner when the data refreshes in the background).
Step 2. Establish the query-key factory.
// src/api/queries.ts
export const invoiceKeys = {
all: ["invoices"] as const,
list: () => [...invoiceKeys.all, "list"] as const,
detail: (id: string) => [...invoiceKeys.all, "detail", id] as const,
byCustomer: (customerId: string) =>
[...invoiceKeys.all, "byCustomer", customerId] as const,
};Every query key in the file goes through the factory. No inline ["invoices", id] arrays — they bypass the type system and break invalidation.
Step 3. Replace mutations.
❌ Before:
const handleDelete = async (id: string) => {
await fetch(`/api/invoices/${id}`, { method: "DELETE" });
// re-run the loading useEffect somehow…
setInvoices((prev) => prev.filter((i) => i.id !== id));
};✅ After:
const queryClient = useQueryClient();
const deleteInvoice = useMutation({
mutationFn: (id: string) =>
fetch(`/api/invoices/${id}`, { method: "DELETE" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: invoiceKeys.all });
},
});
const handleDelete = (id: string) => deleteInvoice.mutate(id);invalidateQueries({ queryKey: invoiceKeys.all }) matches the list, the detail, and every per-customer view in one line. This is why the key factory exists.
Step 4. Audit the default behaviour. TanStack Query refetches on window focus, on reconnect, and at a stale interval by default. Do not disable these by default. The agent will see refetchOnWindowFocus and instinctively turn it off because “the old code didn’t do that.” The old code was buggy. Leave the defaults on.
If a query genuinely should not refetch — a one-shot config fetch at boot — set staleTime: Infinity explicitly and add a comment explaining why.
Step 5. Delete the useState triplet (data, loading, error) for this screen.
Gotchas
⚠ Query keys are not strings. They’re tuples. ["invoices"] and "invoices" are different keys; the second one will not be invalidated by the first. Use the factory.
⚠ isLoading vs isFetching. isLoading is true only when there’s no cached data. After the first load, refetches set isFetching instead. If you render a full-screen spinner on isLoading, the UX is correct. If you render it on isFetching, the UI flickers on every background refetch.
⚠ Stale data on mount. With staleTime: 0 (the default), every mount re-fetches. For fast-changing data (notifications, prices) this is correct. For slow-changing data (user profile, app config) bump staleTime to minutes or hours. Don’t set cacheTime: Infinity — that’s a memory leak waiting to happen.
⚠ Suspense mode is opt-in. useQuery does not throw promises by default. If you migrate a <Suspense> boundary expecting it to “just work,” it won’t — use useSuspenseQuery explicitly.
⚠ Mutations don’t replace the query. A common mistake: writing setInvoices(prev => [...prev, newOne]) inside onSuccess. That mutates local state, but the cache still holds the old list. Either use setQueryData to update the cache, or invalidate the query and let TanStack refetch.
From the audits I’ve done, the most common mistake is disabling refetchOnWindowFocus globally because “it’s noisy in dev.” It’s the single feature that catches stale data in production. Leave it on. If it’s noisy in dev, that’s the bug.
Verification checklist
- No
useEffect+setData+setLoadingtriplets remain in the migrated file. - All query keys constructed from the key factory.
-
QueryClientProvidermounted once at app root, not per-screen. - Devtools (
@tanstack/react-query-devtools) wired in dev only. - Mutations either call
invalidateQueriesorsetQueryData— never both. -
refetchOnWindowFocus,refetchOnReconnect, andretryleft at defaults unless there is a documented reason in a code comment. -
pnpm typecheckpasses. -
MIGRATIONS.mdrow for server state updated.
Recipe 6: Class components → Function components + hooks
Scope
Migrate one class component at a time. Migrate leaves first (components with no child class components) so the call sites you touch are minimal.
In scope:
- Any
class X extends React.Componentorextends PureComponent. componentDidMount,componentDidUpdate,componentWillUnmount.this.stateandthis.setState.this.refsandReact.createRef.- HOC composition (
compose(withRouter, withStyles, withTranslation)(C)).
Explicitly not in scope:
class X extends React.Component<P, S>where the class is an error boundary (usescomponentDidCatchorgetDerivedStateFromError). React still requires a class component for error boundaries. Leave them alone.
Reference implementation
src/components/billing/InvoiceRow.tsx— recently migrated from a class. The PR diff is a useful side-by-side.
Step-by-step migration
Step 1. Translate lifecycle to effects.
❌ Before:
class InvoiceRow extends React.Component<Props, State> {
state = { hover: false };
componentDidMount() {
this.props.subscribe(this.props.invoiceId);
}
componentDidUpdate(prev: Props) {
if (prev.invoiceId !== this.props.invoiceId) {
this.props.unsubscribe(prev.invoiceId);
this.props.subscribe(this.props.invoiceId);
}
}
componentWillUnmount() {
this.props.unsubscribe(this.props.invoiceId);
}
render() { /* ... */ }
}✅ After:
export const InvoiceRow: FC<Props> = ({ invoiceId, subscribe, unsubscribe }) => {
const [hover, setHover] = useState(false);
useEffect(() => {
subscribe(invoiceId);
return () => unsubscribe(invoiceId);
}, [invoiceId, subscribe, unsubscribe]);
// ...
};The mount + update + unmount triple collapses into one useEffect with the right dependency list and a cleanup function. The cleanup runs both when invoiceId changes and when the component unmounts — which is exactly the union of componentDidUpdate (after diffing) and componentWillUnmount.
Step 2. Translate this.state. For independent pieces of state, one useState per piece. For state that updates together as a unit (form state, wizard state, fetch state), use useReducer.
❌ Before:
class Wizard extends React.Component<{}, WizardState> {
state: WizardState = { step: 0, answers: {}, submitting: false };
next = () => this.setState((s) => ({ step: s.step + 1 }));
answer = (k: string, v: string) =>
this.setState((s) => ({ answers: { ...s.answers, [k]: v } }));
}✅ After:
type WizardAction =
| { type: "next" }
| { type: "answer"; key: string; value: string }
| { type: "submit-start" };
const reducer = (s: WizardState, a: WizardAction): WizardState => {
switch (a.type) {
case "next": return { ...s, step: s.step + 1 };
case "answer":
return { ...s, answers: { ...s.answers, [a.key]: a.value } };
case "submit-start": return { ...s, submitting: true };
}
};
export const Wizard: FC = () => {
const [state, dispatch] = useReducer(reducer, {
step: 0,
answers: {},
submitting: false,
});
// ...
};The rule of thumb: if two useState hooks must update in the same render to stay consistent, they should be one useReducer.
Step 3. Translate refs.
❌ Before:
class Modal extends React.Component {
inputRef = React.createRef<HTMLInputElement>();
componentDidMount() { this.inputRef.current?.focus(); }
}✅ After:
export const Modal: FC = () => {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { inputRef.current?.focus(); }, []);
// ...
};Step 4. Translate HOC composition to hook composition.
❌ Before:
export default compose(
withRouter,
withStyles(styles),
withTranslation("invoices"),
)(InvoiceList);✅ After:
export const InvoiceList: FC = () => {
const navigate = useNavigate();
const { t } = useTranslation("invoices");
// styles are now Tailwind utilities; withStyles disappears.
};If a HOC has no hook equivalent (rare in 2026 — most have shipped one), write a custom hook that wraps the HOC’s behaviour. Don’t keep one class component alive just to consume one HOC.
Step 5. Translate this.setState(updater, callback). The callback form has no direct equivalent; useEffect runs after the state update.
❌ Before:
this.setState({ open: true }, () => this.props.onOpen());✅ After:
const [open, setOpen] = useState(false);
useEffect(() => {
if (open) onOpen();
}, [open, onOpen]);
// to open:
setOpen(true);This pattern is verbose but correct. The agent will try to inline onOpen() next to setOpen(true). That works by coincidence in most cases — until a re-render interleaves and the callback fires against stale state. Use the effect.
Gotchas
⚠ Error boundaries stay classes. React has no hook-based error boundary API as of 2026. If the codebase has class ErrorBoundary extends React.Component, leave it alone. Document the exception in MIGRATIONS.md.
⚠ getDerivedStateFromProps is rare and almost always wrong. If you see it in the class, the function-component equivalent is not useEffect(setX, [propX]) — that introduces a render cycle. Compute the derived value directly in render: const derived = computeFrom(propX);.
⚠ shouldComponentUpdate → React.memo. Wrap the function component in React.memo. If the original shouldComponentUpdate did deep comparison, pass a custom comparator to memo — but consider whether the comparison is actually worth the cost; usually it isn’t.
⚠ Stale closures in event handlers. Class methods bound with arrow functions always see the current this. Function-component handlers close over the render in which they were created. If a handler reads state, that read is from the render’s snapshot. Use a useRef or include the value in the dependency array.
⚠ forwardRef is no longer needed in React 19. Function components accept ref as a regular prop. Don’t reintroduce forwardRef during this migration — the agent will instinctively wrap because it learned the pattern from older codebases.
From the audits I’ve done, the most common mistake is migrating a class component that is referenced by ref from a parent, without checking how the parent uses the ref. If the parent calls this.refs.child.someMethod(), you need useImperativeHandle in the new function component — and that almost always means the design should change, not be papered over.
Verification checklist
- No
class <Name> extends React.Componentremains, except documented error boundaries. - No
componentDidMount/componentDidUpdate/componentWillUnmount. - No
this.setState, nothis.state, nothis.props. - No
compose(...)HOC chains in the migrated file. - No
forwardRefreintroduced (React 19 codebase). - Dependency arrays on every
useEffect; ESLintreact-hooks/exhaustive-depspasses. -
pnpm typecheckandpnpm testpass. -
MIGRATIONS.mdupdated; if an error boundary remains, that’s noted as a documented exception, not a Frozen entry.
Picking the order
If your MIGRATIONS.md has rows in three or four of these pairs at once, which do you start with?
| Recipe | Time per unit | Risk | Sequence advice |
|---|---|---|---|
| 1. Redux → Zustand | ~half day per slice | Low | Anytime. Slices are independent. |
| 2. SCSS → Tailwind | ~hour per component | Low | After tokens are in place. |
| 3. MUI v5 → v6 | One sprint | Medium | Do whole-app; don’t half-migrate. |
| 4. Formik → RHF + Zod | ~half day per form | Medium | Field components first, then forms. |
| 5. useEffect → TanStack | ~hour per screen | Low | Anytime; one screen at a time. |
| 6. Class → Function | ~hour per leaf | Low | Leaves first, then containers. |
Recipes 1, 5, and 6 can run in parallel because they touch disjoint files. Recipe 3 should be a single dedicated effort. Recipes 2 and 4 depend on shared primitives (tokens; field components) being in place — Week 2 of the three-week plan from Part 2 sets those up.
The unifying principle across all six: one unit at a time, on main, behind a passing typecheck. A long-lived “migration branch” turns into a long-dead branch. Small PRs into main, every one of them green, every one of them flipping one row of MIGRATIONS.md from ❄️ Frozen to ✅ Current.
What to do with these recipes
- Copy the recipe(s) you need into
docs/prompts/migrate-<x>-to-<y>.md. - Replace the reference implementation paths with paths real for your repo.
- Tighten or loosen the scope and gotchas to match what you’ve actually seen in your audit.
- Hand the recipe plus a scope (“migrate the Customers slice”) to an agent.
- When the agent gets something wrong, fix the recipe — not just the output.
After three or four runs the recipe stabilises. After a quarter, you have a small library that turns the most expensive kind of work in a legacy codebase — incremental migration — into deterministic mechanical work.
MIGRATIONS.md is the map. These six recipes are the moves. The agent does the walking.
Agent-ready React
13 parts in this series.
A six-part series on making legacy React codebases ready for AI coding agents.
- 01 Why Your Legacy React Codebase Confuses AI Coding Agents
- 02 A 3-Week Plan to Make Your Legacy React Codebase Agent-Ready
- 03 Rules That Agents Actually Follow: Enforcement Over Aspiration
- 04 What to Put in design.md: A Complete Template
- 05 Writing Task-Specific Agent Prompts That Work First Try
- 06 Session-Start Hooks That Pay for Themselves
- 07 design.md for an MUI Codebase: A Concrete Template
- 08 design.md for a Chakra UI v3 Codebase: Recipes, Tokens, Rules
- 09 design.md for a Tailwind + shadcn/ui Codebase previous
- 10 MIGRATIONS.md Recipes: Six Concrete Stack-Pair Migrations ← you are here
- 11 The Agent-Ready Audit: A Runnable Checklist for Any React Codebase up next
- 12 How I Actually Wrote This Site's design.md
- 13 design.md, DESIGN.md, and Google Stitch: One File, Narrower Views
What did you take away?
Thoughts, pushback, or a story of your own? Drop a reply below — I read every one.
Comments are powered by Disqus. By posting you agree to their terms.