Changing a Component Everyone Uses: Deprecation, Codemods, and No Big-Bang Rewrites

At the end of the last article I told you to go find the one component everyone reimplements slightly differently. So let's say you found it. You know exactly how it should work now. There's just one problem: a hundred files already import the old one, and half of them belong to teams you've never met.
This is the part of building shared components nobody puts on the roadmap. Writing the new <Button> is the easy 10%. The other 90% is every place that already calls the old <Button>, and the very specific fear that comes with editing code you don't own to fix a component that, technically, still works. That fear is why so many "we'll clean this up later" components never get cleaned up. The change isn't hard. Changing it everywhere, safely, without a flag day is hard.
So this article isn't about how to design a better component. It's about how to move an existing one forward when it already has dozens of dependents — deprecation paths, codemods, and the discipline that lets you touch a hundred call sites without holding your breath.
The big-bang rewrite is the trap
Here's the instinct I have to talk teams out of constantly. You look at the old component, you see everything wrong with it, and you decide to rewrite it properly on a branch. Two weeks later that branch has touched sixty files, it's three hundred commits behind main, every rebase is a knife fight, and the only way to land it is a single terrifying merge that changes everything at once. If anything breaks — and something always breaks — you can't tell which of the sixty changes did it.
That's a flag day: a moment where the whole codebase has to flip from old to new simultaneously. Flag days feel decisive and they are almost always a mistake. The problem isn't your ability to write the new component. The problem is that you tried to break and rebuild in the same step, so there's never a moment where the system is in a known-good state. You're either fully migrated or fully broken, with nothing in between.
The fix is to refuse the whole framing. You don't rewrite. You evolve.
You never break and rebuild in one step. You add the new path, move callers over one at a time, then delete the old path. Three boring, reversible steps instead of one heroic irreversible one.
That shape has a few names — parallel change, expand/contract, the strangler pattern — but at the component level it's just this: expand the component so old and new both work, migrate the call sites gradually, then contract by removing the old way once nothing uses it. Every intermediate state ships. Every step is revertible on its own. Nothing has a flag day.
Step one: make the change additive
The whole strategy depends on old and new coexisting for a while. So the first move is to add the new API next to the old one instead of replacing it. Say I'm moving a <Button> from a raw color prop to a semantic intent prop — because color="green" tells you nothing about why it's green, and the theme can't remap it.
The tempting move is to rename color to intent and let TypeScript light up a hundred errors. Don't. Add intent, keep color, and translate internally:
type ButtonProps = { /** @deprecated Use `intent` instead. `color` is removed in v3. */ color?: 'green' | 'red' | 'gray'; intent?: 'success' | 'danger' | 'neutral'; children: React.ReactNode; }; const colorToIntent = { green: 'success', red: 'danger', gray: 'neutral', } as const; export function Button({ color, intent, children }: ButtonProps) { // new prop wins; old prop still works via a mapping const resolved = intent ?? (color ? colorToIntent[color] : 'neutral'); if (process.env.NODE_ENV !== 'production' && color && !intent) { console.warn( `Button: the \`color\` prop is deprecated. Use intent="${colorToIntent[color]}" instead.`, ); } return <button className={intentClasses[resolved]}>{children}</button>; }
Nobody's build breaks. Every existing color="green" keeps rendering exactly as before. New code uses intent. And critically, the component itself now knows the mapping from old to new — which is going to matter in about thirty seconds when we automate the migration.
A backward-compatible change buys you time, and time is the one thing a big-bang rewrite refuses to give you. Coexistence isn't a compromise. It's the whole plan.
Step two: deprecation is a message, not a delete
Marking something @deprecated is not the same as deleting it, and treating them as the same thing is why deprecations get ignored. A deprecation is a communication channel. Its entire job is to tell the next person who touches this code what to do instead — clearly, at the exact moment they'd otherwise reach for the old thing.
That JSDoc @deprecated tag isn't decorative. Editors strike through the prop and surface the message on hover, so a developer typing color= sees the replacement before they even hit save. The runtime warning catches the code that's already written. And if you want teeth, an ESLint rule turns "please stop" into "the build is yellow until you stop":
// eslint config — nudge, don't block yet 'no-restricted-syntax': [ 'warn', { selector: 'JSXAttribute[name.name="color"]', message: 'Button: the color prop is deprecated — use intent. Run the codemod: npx tsx scripts/button-color-to-intent.ts', }, ],
The one rule I hold to: a deprecation must always name its replacement. "This is deprecated" with no path forward isn't a deprecation, it's nagging. Every warning I write ends with the exact thing to type instead, and ideally the command that'll do it for you. Which brings us to that command.
Step three: codemods move the boring changes for you
Here's where the hundred call sites stop being scary. If the change is mechanical — color="green" becomes intent="success", every time, no judgment required — then asking humans to do it a hundred times is both slow and a source of typos. A codemod is a script that rewrites source code by editing its syntax tree, and this is exactly what they're for.
// scripts/button-color-to-intent.ts — jscodeshift transform import type { API, FileInfo } from 'jscodeshift'; const map: Record<string, string> = { green: 'success', red: 'danger', gray: 'neutral' }; export default function transform(file: FileInfo, api: API) { const j = api.jscodeshift; const root = j(file.source); root .find(j.JSXAttribute, { name: { name: 'color' } }) // only touch color props on <Button> .filter((path) => { const el = path.parent.node; return el.name?.type === 'JSXIdentifier' && el.name.name === 'Button'; }) .forEach((path) => { const value = path.node.value; if (value?.type === 'Literal' && typeof value.value === 'string' && map[value.value]) { path.node.name.name = 'intent'; value.value = map[value.value]; } }); return root.toSource(); }
Run it across the repo and every <Button color="green"> becomes <Button intent="success"> in one commit — a commit a reviewer can actually read, because the diff is uniform and mechanical. That's the real payoff: not just speed, but a reviewable migration instead of a hundred hand-edited files where any one of them could hide a mistake.
Codemods aren't always the answer, though, and I've watched people burn a day writing one for a change they could've done by hand in ten minutes. My test is two questions. Are there enough call sites that doing it manually is genuinely painful — dozens, not five? And is the transform mechanical, with no case where a human has to decide what the right replacement is? If either answer is no, skip the codemod. When the change needs judgment — "replace this prop, but only where the old behavior was actually intended" — a script can't make that call, and pretending it can just moves the bugs somewhere quieter.
When it can't be additive: version it side by side
Sometimes the new component isn't a tweaked version of the old one — it's a different thing that happens to share a name. A <DataTable> that moves from a props-driven column config to a headless row model isn't a new prop; it's a new mental model. You can't map the old API onto the new one, so coexistence-through-props doesn't work.
That's when you version at the module level instead of the prop level. Ship the new one alongside the old under its own name, and let call sites move when they're ready:
// old one keeps living, untouched, so nothing breaks export { DataTable } from './data-table-legacy'; // new one ships next to it under a clear name export { DataTableV2 } from './data-table-v2';
It's the same expand/migrate/contract shape, just one level up. Both exist. Features migrate to DataTableV2 on their own schedule, each migration a small reviewable change instead of a codebase-wide flip. When the last import of the legacy one is gone, you delete it. The name V2 is ugly on purpose — it's a temporary state that's supposed to disappear, and its ugliness is a standing reminder that the migration isn't finished. If DataTableV2 is still around in a year, that's a signal, not a feature.
Step four: contract — actually delete the old thing
This is the step everyone skips, and skipping it quietly undoes all the discipline that came before. Expand and migrate without contract just leaves you with two ways to do everything, forever, plus a warning nobody reads anymore. The deprecation was a promise to remove the old path. Contract is keeping it.
The good news is that after the additive change, the deprecation, and the codemod, contract is almost anticlimactic. You check that usage has actually hit zero — and you gate it, don't eyeball it:
# fails CI if a single old-style usage survives grep -rn 'color=' src/ --include='*.tsx' && echo "still used — not safe to remove" && exit 1
When that returns nothing, you delete the color prop, the mapping, the warning, and the ESLint rule in one small commit. The component gets simpler — which is the tell that you did the whole thing right. A migration that ends with more code than it started with wasn't a migration, it was an accretion.
What this really changes
The thing I want to leave you with isn't the codemod or the deprecation tag — it's the reframing. Once you stop seeing "change a shared component" as one scary event and start seeing it as expand, migrate, contract, the fear mostly evaporates. Not because the work got smaller, but because every step is now individually safe and individually revertible. You're never one merge away from a broken build. You're always in a known-good state that happens to support two ways of doing something for a while.
And honestly, the willingness to make this move is a large part of what separates a component library people trust from one they're afraid of. A kit whose owners can evolve it safely keeps getting better. A kit whose owners are scared to touch it ossifies — every rough edge becomes permanent because fixing it feels too risky. The techniques here are what buy you the confidence to keep improving the thing everyone depends on.
Which raises the harder version of the same problem, and where I want to go next: it's one thing to evolve a component inside one repo where you can grep every call site. It's another to keep a shared UI kit in sync across several applications that each pull it in as a versioned package — where you can't see the call sites, can't run a codemod across them, and "just delete the old prop" can break an app you don't even have checked out. That's the next article.
If you've got a shared component you've been too scared to change, I'd genuinely like to hear what's holding it back — is it the call-site count, a missing safety net, or just that nobody's allowed to own it? Reach out and tell me which.
Found this helpful?
Subscribe on Telegram for new posts, or reach out to discuss your project.