React + TypeScript Refactoring: A Field Guide
Most React apps don't break. They just get slow to change.
The code still runs. Users are happy. But a two-line change takes a day, and nobody can explain why moving a button broke the checkout. You open the repo and find a 900-line component that imports 31 things, throws any at every API response, and has a useEffect with a dependency array someone gave up on years ago.
That is the usual starting point when a team asks me to refactor. The code isn't broken. It's expensive. Every change costs more than it should, and the cost keeps growing. This is a field guide to fixing that. What the symptoms look like, what to fix first, and how to do it without a six-month rewrite that gets cancelled in month three.
The Signs
You don't need a metric for most of these. You can feel them.
any creep. Someone hit a type error in 2022, typed : any to make it go away, and now there are 400 of them. Every any is a hole in your safety net, and they spread. One untyped value flows into five typed functions and erases the guarantees in all of them. The compiler is on, but it's lying to you.
Prop-drilling. A value gets fetched at the top of the tree and threaded through eight components that don't use it, just to reach the one that does. Every component in the chain has to know about a prop that is none of its business. Add a field and you edit nine files.
The 800-line god component. It fetches data, holds a dozen pieces of state, renders three different layouts, and contains the business logic for all of them. Nobody reads it top to bottom. They Ctrl+F to the line they need, change it, and pray.
Untyped API boundaries. const data = await res.json() returns any, and from that point your "fully typed" app is built on sand. The backend changes a field from string to string | null, your types still say string, TypeScript stays quiet, and you find out in production when .toLowerCase() throws.
useEffect spaghetti. Effects that sync state to other state. Effects that fight each other. Effects with // eslint-disable-next-line react-hooks/exhaustive-deps because the honest dependency array caused an infinite loop. This is where the heisenbugs live.
No test seams. You can't test the logic because it's welded to the rendering. To test whether a discount calculates right, you'd have to mount the whole component, mock four hooks, and query the DOM. So nobody writes the test.
Slow onboarding. A senior dev joins and still can't ship with confidence after two weeks. That is not the dev. That is the codebase charging a tax on every hire, forever.
If you're nodding at three or more of these, you don't have a code problem. You have a structure problem, and structure is fixable.
What to Fix First
Order matters here more than anything. People want to start with the fun part: performance, React.memo, code-splitting. That comes last. You can't safely optimize code you can't safely change, and you can't safely change code the compiler doesn't understand. Do it in this order.
1. Turn on stricter TypeScript, incrementally
Big-bang strict: true on a large codebase floods you with 2,000 errors and demoralizes everyone. Turn the screws one at a time. Start with noImplicitAny, fix the fallout, commit. Then strictNullChecks. This is the one that catches real bugs, the undefined you forgot to handle. Then the rest of the strict family.
The goal isn't strict for its own sake. It's killing any with real types, because every any you replace turns a class of runtime crash into a compile error you fix in seconds. Ban it once you're clean ("@typescript-eslint/no-explicit-any": "error") so it can't creep back. Where you genuinely don't know a shape yet, use unknown and narrow it. unknown forces you to check before you use. any lets you lie.
2. Extract custom hooks
This is the highest-leverage move in a React refactor. Pull the tangled useEffect and state logic out of components and into named hooks. The component becomes a thing that renders. The logic becomes a thing you can test on its own. Now useCheckout() has a name, a signature, and a test file, instead of being 60 lines buried in the middle of a JSX return.
3. Fix component boundaries and colocate state
Once the logic is in hooks, the god component falls apart on its own. You split it along the seams the hooks reveal. The hard rule that fixes prop-drilling: state lives as close as possible to where it's used. If only the modal needs isOpen, it doesn't belong in the page. Pull state down, not up. Most prop-drilling disappears the moment state stops being hoisted to the top out of habit. For the genuinely global stuff, reach for context or a store. But that is the exception, not the default.
4. Type the API/data boundary
Your app should have a hard shell. Nothing untyped gets in. The cleanest way is to validate at the edge with a schema library. I reach for zod, so data is checked at runtime, not just assumed at compile time. The parse either gives you a value you can trust everywhere downstream, or it throws right at the boundary where the bug actually is.
5. Performance, LAST
Now, and only now, you profile and fix what is actually slow. memo the components that re-render for no reason, useMemo the genuinely expensive computations, code-split the routes. By this point your components are small and your data flow is clear, so the slow paths are obvious and the fixes are safe. Optimizing first is how you spend a week memoizing a component that wasn't the bottleneck.
A Before / After
Here is a shape I see constantly. Fetch logic and any baked into the component:
// Before: untyped, untestable, 'any' at the boundary
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => {
setUser(data); // 'any': the compiler now trusts whatever the server sends
setLoading(false);
});
}, [userId]);
if (loading) return <Spinner />;
return <h1>{user.name}</h1>; // crashes if 'name' is missing, no warning
}
And after. Schema at the edge, logic in a hook, component does nothing but render:
// user.ts: the boundary is typed and validated
import { z } from 'zod';
const User = z.object({ id: z.string(), name: z.string() });
type User = z.infer<typeof User>;
// use-user.ts: testable in isolation, no JSX in sight
function useUser(userId: string) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let active = true;
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(raw => User.parse(raw)) // throws here, at the boundary, if the shape is wrong
.then(data => {
if (active) {
setUser(data);
setLoading(false);
}
});
return () => {
active = false; // no setState on an unmounted component
};
}, [userId]);
return { user, loading };
}
// UserProfile.tsx: just renders
function UserProfile({ userId }: { userId: string }) {
const { user, loading } = useUser(userId);
if (loading) return <Spinner />;
if (!user) return null;
return <h1>{user.name}</h1>; // 'user' is a real User; the compiler guarantees it
}
Same behavior. But now useUser has a test, User.name can't be undefined, the effect cleans up after itself, and the component is four lines you can read at a glance. Multiply that across a codebase and you see why the speed comes back.
How to Do It Without Blowing Up Production
The strategy is strangler-fig, not rewrite. You don't stop the world and rebuild. You wrap the old code, route new work through the new structure, and starve the legacy paths until they're empty, then you delete them. The rewrite-from-scratch is the most reliable way I know to spend a year and ship nothing. The old app keeps gaining features while your replacement chases a moving target.
Three rules make incremental refactoring survivable:
- Feature flags. Put refactored paths behind a flag and roll them to a slice of traffic first. If the new typed checkout misbehaves, you flip it off in seconds instead of reverting a deploy.
- One concern per PR. "Enable
strictNullChecksfor the auth module" is a reviewable PR. "Refactor the app" is not. Small, single-purpose changes are the only ones that get reviewed properly and rolled back cleanly. - Never refactor and ship a feature in the same PR. This is the one I will die on. The instant a diff contains both, the reviewer can't tell which change caused which effect, and a regression three weeks later is impossible to bisect. Branch, refactor, test, merge. Then build the feature on the clean foundation.
That last rule is also the honest answer to "we don't have time to refactor." You're not asking for a refactoring sprint. You're refactoring the file you're already in, in its own PR, the week before you touch it for the feature. The cleanup and the feature ship days apart, on the same roadmap.
If a lot of this sounds Web3-shaped to you, the same spine applies with chain-specific twists. I wrote those up separately in Web3 code refactoring. But everything above is plain React and TypeScript, and it's where most of the pain actually lives.
So, which of those seven signs is your codebase showing right now?
If your React app has these symptoms and you'd rather have someone who's untangled a few large codebases lead the work, I do code refactoring consulting. Fixed scope, no ongoing commitment.