React & TypeScript Refactoring Decisions
A list of the most common refactors that pay off on real React and TypeScript code - each with a bold label, a one-sentence rationale, and a short before/after snippet. Use it as a pre-PR checklist, a code review reference, or a spike-hunting audit on an existing codebase.
How to Use This List
- Walk each item; if a pattern matches, open a small, scoped PR per decision.
- Prefer small refactors paired with the feature work that exposes them - not big-bang rewrites.
- Keep the "before" diff in the PR description so reviewers see the smell you removed.
Refactoring Decisions
-
Extract magic values to named constants - Replaces scattered literals with a single source of truth so tweaks don't require a grep-and-pray across the repo.
// before if (retries < 3) retry(); // after const MAX_RETRIES = 3; if (retries < MAX_RETRIES) retry(); -
Replace
anywithunknownand narrow - Forces callers to prove the shape before use, catching bad assumptions at the boundary instead of deep in the UI.// before function parse(data: any) { return data.items.map(...); } // after function parse(data: unknown) { if (!isPayload(data)) throw new Error("bad payload"); return data.items.map(...); } -
Prefer discriminated unions over optional props - Makes impossible states unrepresentable so TypeScript rejects
loading: truewithdata: Fooat compile time.// before type State = { loading: boolean; data?: Foo; error?: Error }; // after type State = | { status: "loading" } | { status: "success"; data: Foo } | { status: "error"; error: Error }; -
Extract repeated effects into a custom hook - DRYs cross-cutting logic (polling, subscriptions, focus traps) and makes it unit-testable in isolation.
// before useEffect(() => { const id = setInterval(fetchStatus, 5000); return () => clearInterval(id); }, []); // after usePolling(fetchStatus, 5000); -
Replace prop drilling with context or a store - Removes pass-through props from components that don't care about the value, so adding a new consumer doesn't touch the middle layers.
// before <Page user={user}><Toolbar user={user}><Avatar user={user} /></Toolbar></Page> // after <UserProvider value={user}><Page><Toolbar><Avatar /></Toolbar></Page></UserProvider> -
Compute derived state during render instead of storing it - Eliminates a whole class of "these two states disagree" bugs by making the derivation the single source of truth.
// before const [items, setItems] = useState<Item[]>([]); const [count, setCount] = useState(0); // drifts // after const [items, setItems] = useState<Item[]>([]); const count = items.length; -
Memoize expensive computations with
useMemo- Skips repeat work on renders caused by unrelated state, turning a visible input lag into instant feedback.const sorted = useMemo(() => bigList.sort(compareFn), [bigList]); -
Wrap callbacks in
useCallbackonly when passed to memoized children - KeepsReact.memoboundaries stable so descendants skip rerenders instead of breaking their memoization.const onSelect = useCallback((id: string) => setSelected(id), []); return <MemoizedList onSelect={onSelect} />; -
Split oversized components by responsibility - Narrows the rerender scope and the review scope so a change to the header doesn't cause a diff in the footer.
// before: 400-line <Dashboard /> doing header, filters, table, pagination // after <Dashboard><Header /><Filters /><Table /><Pagination /></Dashboard> -
Replace tangled
useStatewithuseReducer- Centralizes related transitions in one function so invariants can be enforced instead of scattered across handlers.const [state, dispatch] = useReducer(cartReducer, initialCart); dispatch({ type: "add", item }); -
Convert class components to function components - Unlocks hooks, smaller bundles, and modern patterns like Suspense and Server Components.
// before class Timer extends React.Component { /* lifecycle methods */ } // after function Timer() { useEffect(() => { /* ... */ }, []); return <span /> } -
Use
as constfor literal inference - Pins string and array values to their exact literal types so they flow through generics instead of widening tostring.// before const roles = ["admin", "user"]; // string[] // after const roles = ["admin", "user"] as const; // readonly ["admin", "user"] -
Replace
enumwith anas constobject union - Produces better tree-shaking, friendlier JSON, and clearer types without TypeScript's enum runtime quirks.// before enum Status { Open, Closed } // after const Status = { Open: "open", Closed: "closed" } as const; type Status = (typeof Status)[keyof typeof Status]; -
Lift shared types to a dedicated module - Prevents type drift when the same shape is redeclared in three components with slightly different fields.
// src/types/user.ts export interface User { id: string; email: string; roles: Role[] } -
Use
satisfiesto validate without widening - Keeps the narrow inferred type while still checking the value conforms, so autocomplete stays sharp.const routes = { home: "/", admin: "/admin", } satisfies Record<string, `/${string}`>; -
Replace array-index keys with stable IDs - Fixes reorder bugs, input focus loss, and animation glitches caused by React reusing the wrong DOM node.
// before items.map((item, i) => <Row key={i} {...item} />) // after items.map((item) => <Row key={item.id} {...item} />) -
Replace
useEffectfetches with TanStack Query (or SWR) - Gets caching, deduplication, retries, and request cancellation for free, deleting dozens of lines of loading/error boilerplate.const { data, isLoading } = useQuery({ queryKey: ["user", id], queryFn: () => fetchUser(id) }); -
Validate external data with Zod at the boundary - Converts "undefined is not a function" crashes deep in the UI into a single, explicit parse failure at the edge.
const User = z.object({ id: z.string(), email: z.string().email() }); const user = User.parse(await res.json()); -
Split a mega-context into targeted contexts - Stops every consumer from rerendering when an unrelated slice changes, turning a sluggish app into a snappy one.
<AuthProvider> <ThemeProvider> <CartProvider>{children}</CartProvider> </ThemeProvider> </AuthProvider> -
Colocate state with the component that owns it - Shrinks reasoning scope and makes deletion trivial when the feature goes away.
// before: modal open state in a global store // after: useState inside <Modal /> itself const [open, setOpen] = useState(false); -
Replace boolean-prop soup with a
variantunion - Eliminates impossible combinations likeprimary && danger && outlineat the type level.// before <Button primary danger outline /> // after <Button variant="danger-outline" /> -
Extract form logic into react-hook-form + Zod - Removes controlled-input churn, centralizes validation, and makes the form declarative instead of imperative.
const form = useForm<FormValues>({ resolver: zodResolver(Schema) }); <input {...form.register("email")} /> -
Replace nested ternaries with early returns - Flattens branching so the happy path reads top-to-bottom without holding a parse tree in your head.
// before return loading ? <Spinner /> : error ? <Error /> : data ? <View data={data} /> : null; // after if (loading) return <Spinner />; if (error) return <Error />; if (!data) return null; return <View data={data} />; -
Convert imperative DOM ops to declarative state - Aligns with React's mental model so rerenders can't accidentally undo a manual DOM mutation.
// before useEffect(() => { ref.current!.classList.add("open"); }, [open]); // after <div className={open ? "open" : ""} /> -
Use optional chaining and nullish coalescing - Replaces guard pyramids with a single expression that still handles the null/undefined cases explicitly.
// before const name = user && user.profile && user.profile.name ? user.profile.name : "guest"; // after const name = user?.profile?.name ?? "guest"; -
Move static UI to a Server Component - Ships less JavaScript to the client and keeps data-fetching close to the source without a hydration roundtrip.
// app/page.tsx (Server Component by default in App Router) export default async function Page() { const posts = await db.posts.findMany(); return <PostList posts={posts} />; } -
Lazy-load heavy routes and widgets - Keeps the initial bundle small so the first interactive paint doesn't wait on a chart library the user may never open.
const Chart = lazy(() => import("./Chart")); <Suspense fallback={<Spinner />}><Chart /></Suspense> -
Replace
React.FCwith an explicit prop type - Gives you control over thechildrencontract and matches the current community consensus on component typing.// before const Card: React.FC<{ title: string }> = ({ title, children }) => <div>{title}{children}</div>; // after function Card({ title, children }: { title: string; children?: React.ReactNode }) { return <div>{title}{children}</div>; } -
Wrap risky subtrees in an error boundary - Isolates blast radius so a broken chart doesn't take down the whole dashboard.
<ErrorBoundary fallback={<ChartError />}> <Chart /> </ErrorBoundary> -
Drop loading flags by pairing Suspense with a data library - Removes
isLoadingbranches in favor of a single fallback boundary, letting components assume data is present.<Suspense fallback={<Skeleton />}> <UserProfile id={id} /> {/* reads data via `use` or a suspending query */} </Suspense>
Applying the List in Order
- Types tier (2, 3, 12-15, 28): cheap, compile-time wins - land these first; they unlock safer refactors downstream.
- Render-correctness tier (6, 10, 16, 23, 24): removes bug classes, not just noise - prioritize on any component that has logged regressions.
- Performance tier (7-9, 19, 26, 27, 30): only after a real measurement - don't speculate; profile first.
- Data tier (17, 18, 30): usually the biggest single-PR win in legacy codebases - aim for one area at a time.
- Structure tier (1, 4, 5, 11, 20, 21, 22, 25, 29): lands alongside feature work; resist the urge to bundle them into a mega-refactor PR.
Gotchas
- Refactoring without tests - Pure-render extractions are safe; logic moves aren't. Fix: land a characterization test before touching behavior.
- Premature memoization -
useMemo/useCallbackeverywhere adds noise without measurable wins. Fix: only memoize after a profiler trace or when a downstreamReact.memodepends on referential stability. - Mega-PR refactors - A "cleanup PR" with 40 files is unreviewable and conflict-prone. Fix: one decision per PR, stacked if needed.
- Refactor for future features - Shaping code for a feature that may never ship is sunk cost. Fix: refactor the minute before the feature lands, not weeks before.
- Type-only refactors chasing 100% strictness - Replacing every
anyin one go creates huge diffs with no behavior change. Fix: enable strict flags incrementally and fix drift at the module boundary.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Codemods (jscodeshift, ts-morph) | The refactor is mechanical and repeats across hundreds of files. | The change needs human judgment per site. |
| Big-bang rewrite | The old code is fundamentally unsafe and no incremental path exists. | Incremental refactors are possible - they almost always are. |
| Strangler fig pattern | You're migrating off a legacy framework or pattern gradually. | The codebase is small enough to refactor in one sweep. |
| Deprecation comments + lint rules | You can't refactor all call sites immediately but want to prevent new uses. | The pattern is already fully removable. |
FAQs
Which of these refactors should I tackle first?
- Start with the types tier (items 2, 3, 12-15, 28) - they are compile-time only, low risk, and make the rest safer.
- Then hit data-layer refactors (items 17, 18, 30) for the biggest single-PR code reduction.
- Leave performance (items 7-9) for last - only touch after a real profiler trace.
When is useMemo or useCallback actually worth it?
- When the value is passed to a
React.memo'd child that would otherwise rerender on every parent render. - When the computation itself is measurably expensive (sorting/filtering large lists, heavy derived data).
- Not as a default - speculative memoization adds noise and can even slow things down via deps array churn.
Why prefer discriminated unions over optional props?
- They make impossible states unrepresentable -
loading: truewithdata: Foosimply won't type-check. - Consumers can exhaustively switch on the discriminator and let TypeScript catch missing cases.
- Optional props let you accidentally forget to clear
errorwhen loading restarts - unions won't.
Should I always replace enum with as const objects?
- For string-like lookups, yes - better tree-shaking, cleaner JSON, no runtime enum reverse-mapping surprises.
- Numeric enums can stay if you're doing bitwise flags or you need iteration over values.
- The biggest win is that
as constobjects compose naturally withsatisfiesand template literal types.
How do I know when to split a big context?
- If changing one slice (theme) rerenders consumers of unrelated slices (cart), you've outgrown a single context.
- A quick React DevTools profile on a targeted interaction will show the rerender cascade clearly.
- Split by update frequency: slow-moving values (auth, theme) in one context, fast-moving values (cart, selection) in another or a store.
Isn't any sometimes unavoidable?
- Almost never in product code -
unknownplus a narrowing function is safer in every caseanyis tempting. - Library internals dealing with truly dynamic generics (Zod, TanStack Query) are the honest exception.
- If you must use
any, confine it to a single function and comment why - don't let it leak across module boundaries.
When should I refactor a class component to a function component?
- When you're already touching the file - don't do it as a standalone PR unless a hook is specifically needed.
- When the lifecycle methods are a poor fit for the logic (e.g., subscription cleanup spread across three methods).
- Never as a Friday-afternoon sweep - classes work fine; the value is in unlocking hooks or modern features.
How aggressive should I be about deleting useEffect for data fetching?
- Very - effect-based fetching misses caching, dedupe, retries, and cancellation that libraries give you for free.
- Migrate one hot path (usually the dashboard or list view) and watch the loading-state boilerplate vanish.
- Reserve
useEffectfor true side-effects to the outside world: subscriptions, timers, imperative APIs.
Why not just ship one big refactor PR?
- Big PRs are unreviewable - reviewers rubber-stamp them and subtle regressions slip through.
- Rebase conflicts compound with team velocity; small PRs land before conflicts matter.
- One-decision-per-PR gives you a clean git history and an easy revert path when something breaks.
What's the minimum test coverage before refactoring?
- At least one integration test per user-visible path you're touching - doesn't matter if it's Playwright or Testing Library.
- Type-only refactors are safe without tests because a miss will surface at compile time.
- Logic moves (reducer consolidation, hook extraction) need a before-test that locks current behavior in place.
How do I stop piling up useState calls in a single component?
- If two or more fields update together, fold them into a
useReducerwith named actions. - If the derived value can be computed from existing state, compute it during render instead of storing it.
- If the state doesn't drive rendering, consider
useRefinstead.
What's the cheapest way to find refactor candidates in an existing codebase?
- Grep for
any,as any,@ts-expect-error, and// eslint-disable- they mark the rot. - Run the React DevTools profiler on the top three user paths and look for avoidable rerenders.
- Sort files by line count; anything over ~400 lines is almost always a split candidate.
Related
- React Architecture Decision Checklist - Stack-level decisions that precede code-level refactors.
- React Architecture Gotchas & Common Mistakes - Architectural anti-patterns this list helps you refactor out.
- TypeScript + React - Deeper dives into the typing patterns referenced above.
- React Fundamentals Best Practices - Component-level rules the refactors here align to.