Treat this as a checklist: skim the 20 titles first and mark which ones describe code you maintain right now.
The "Why this works" line is the part to memorize - it tells you when the same refactor applies to a different file.
Most refactors here are PR-sized. If a single item turns into a multi-day project, the diagnosis is probably wrong.
Ship one refactor per PR. Bundling a Server Component conversion with a typing pass makes review hostile.
"The <Dashboard /> file keeps growing every sprint. Reviewers spend 40 minutes on every PR and still miss things. Can we clean it up?"
Diagnosis: One component renders the layout, fetches three resources, formats them, tracks analytics, and owns four pieces of UI state. It violates single-responsibility on every axis.
Refactor:
// app/dashboard/page.tsx (Server Component)
export default async function Page () {
const [ user , metrics , alerts ] = await Promise . all ([
getUser (), getMetrics (), getAlerts (),
]);
return < DashboardShell user = {user} metrics = {metrics} alerts = {alerts} />;
}
// DashboardShell.tsx
export function DashboardShell ({ user , metrics , alerts } : Props ) {
return (
< Layout >
< Header user = {user} />
< MetricsPanel metrics = {metrics} />
< AlertsPanel alerts = {alerts} />
</ Layout >
);
}
Why this works: Each child has one reason to change. Reviewers diff one panel at a time, and the Server Component can fetch in parallel without a useEffect.
"The currentUser prop is threaded through Layout, Sidebar, Nav, NavItem, and finally LogoutButton. Adding a field to the user object means editing five files."
Diagnosis: Prop drilling. The intermediate components do not use user - they only forward it.
Refactor:
// lib/user-context.tsx
"use client" ;
const UserContext = createContext < User | null >( null );
export const useUser = () => useContext (UserContext) ! ;
export function UserProvider ({ user , children } : { user : User ; children : ReactNode }) {
return < UserContext.Provider value = {user}>{children}</ UserContext.Provider >;
}
// LogoutButton.tsx
const user = useUser ();
Why this works: Context flattens the dependency. Adding a new field touches the provider and the consumer, not the four components in the middle.
"Users complain that the product page shows a loading skeleton for half a second even when they have already visited it. Can we make it feel instant?"
Diagnosis: A Client Component fetches in useEffect. The server returns no data, the client mounts, then fetches. The flash is unavoidable in this shape.
Refactor:
// app/products/[id]/page.tsx
export default async function Page ({ params } : { params : Promise <{ id : string }> }) {
const { id } = await params;
const product = await getProduct (id);
return < ProductView product = {product} />;
}
Why this works: Moving the fetch to a Server Component bakes the data into the first HTML response. No spinner, no client waterfall, no useEffect.
"Lighthouse says we ship 600 KB of JS for a page that is mostly static content. Tech lead flagged that everything is a Client Component."
Diagnosis: "use client" sits at the top of the route wrapper. Every static heading, sidebar, and footer travels to the browser as JS.
Refactor:
// app/dashboard/page.tsx - no directive
export default function Page () {
return (
< Layout >
< Header />< Sidebar />
< InteractiveChart />{ /* this one is "use client" */ }
</ Layout >
);
}
Why this works: The boundary lives on the smallest interactive leaf. Static markup stays on the server and ships as HTML, not JS.
"We changed 'editor' to 'author' in the schema and three checks still reference the old string. QA caught one of them by accident."
Diagnosis: String literals scattered across components. No central definition, no type safety.
Refactor:
// lib/roles.ts
export const ROLES = { admin: "admin" , author: "author" , viewer: "viewer" } as const ;
export type Role = ( typeof ROLES )[ keyof typeof ROLES ];
// usage
if (user.role === ROLES .author) { /* ... */ }
Why this works: as const makes the values literal types. Renaming the constant fails to compile in every consumer instead of silently misbehaving.
"Errors in Sentry mention Cannot read properties of undefined. The fetch result is typed any and we accessed data.user.profile.name."
Diagnosis: any short-circuits every check. The API response shape is unknown to the compiler.
Refactor:
import { z } from "zod" ;
const User = z. object ({ profile: z. object ({ name: z. string () }) });
const res = await fetch ( "/api/user" );
const user = User. parse ( await res. json ());
Why this works: Parsing at the boundary turns "any" into a known type. Bad responses throw at the edge instead of crashing four components deeper.
"Every button call has isPrimary isLarge isLoading isDisabled isGhost isIcon ... and it's impossible to tell what renders."
Diagnosis: Boolean prop soup. Mutually exclusive states are not modeled as exclusive.
Refactor:
type Variant = "primary" | "secondary" | "ghost" | "danger" ;
type Size = "sm" | "md" | "lg" ;
type Props = { variant ?: Variant ; size ?: Size ; loading ?: boolean ; icon ?: ReactNode };
< Button variant = "primary" size = "lg" loading = {isSaving}>Save</ Button >
Why this works: Unions encode the rule "only one variant at a time" in the type system. Invalid combinations stop compiling.
"Every call site looks like createInvoice(id, '', null, true, false, 0, null, 'usd', undefined) and we keep passing the wrong null."
Diagnosis: Positional args degrade rapidly past three. The reader cannot tell what each one means without opening the definition.
Refactor:
type CreateInvoiceInput = {
customerId : string ;
note ?: string ;
draft ?: boolean ;
currency ?: "usd" | "eur" ;
};
export function createInvoice ( input : CreateInvoiceInput ) { /* ... */ }
createInvoice ({ customerId: id, draft: true , currency: "usd" });
Why this works: Named keys document themselves at the call site. Adding a new option is non-breaking; defaults live in one place.
"Search, the sidebar, the inbox badge, and the header all call /api/notifications. They drift constantly and one always has a stale cache."
Diagnosis: Duplicated data-loading logic. Each component has its own useEffect, its own loading state, and its own retry behavior.
Refactor:
// hooks/use-notifications.ts
"use client" ;
import { useQuery } from "@tanstack/react-query" ;
export function useNotifications () {
return useQuery ({
queryKey: [ "notifications" ],
queryFn : () => fetch ( "/api/notifications" ). then ( r => r. json ()),
});
}
Why this works: One cache key, one fetcher, one source of truth. Invalidations propagate to all subscribers automatically.
"The signup form keeps growing as we add fields. Validation rules live in a giant if ladder and we ship validation bugs every release."
Diagnosis: Imperative state management for a form that should be declarative.
Refactor:
"use client" ;
import { useForm } from "react-hook-form" ;
import { zodResolver } from "@hookform/resolvers/zod" ;
import { z } from "zod" ;
const Schema = z. object ({
email: z. string (). email (),
password: z. string (). min ( 12 ),
});
const { register , handleSubmit , formState : { errors } } = useForm ({
resolver: zodResolver (Schema),
});
Why this works: The schema is the spec. Validation, error display, and types come from the same source - adding a field is a one-line change.
"ESLint warns on UNSAFE_componentWillReceiveProps in <NotificationBell />. Tech lead says convert it before the React 19 upgrade."
Diagnosis: Legacy class lifecycle that has no direct hooks equivalent and breaks under Strict Mode double-invocation.
Refactor:
"use client" ;
export function NotificationBell ({ count } : { count : number }) {
const [ seen , setSeen ] = useState (count);
useEffect (() => {
if (count > seen) playSound ();
setSeen (count);
}, [count, seen]);
return < Badge value = {count} />;
}
Why this works: useEffect plus state captures the same intent - "do something when count changes" - without lifecycle traps or this binding.
"Delete row 2 in the table and row 3 becomes 'selected'. Sometimes the wrong item gets deleted entirely."
Diagnosis: key={i} on the row mapping. React reuses DOM nodes by position, so state attaches to the wrong row after a delete.
Refactor:
{rows. map (( row ) => (
< Row key = {row.id} { ... row} />
))}
Why this works: Stable identity. React tracks each row by its real id, so removing an item does not shift internal state onto the next one.
"Adding a new event type means scrolling through 400 lines of case. People copy the wrong block and break two events at once."
Diagnosis: Open-coded dispatch. The control flow encodes data.
Refactor:
const handlers = {
"order.created" : handleOrderCreated,
"order.refunded" : handleOrderRefunded,
// ...
} satisfies Record < EventType , EventHandler >;
handlers[event.type](event);
Why this works: Dispatch becomes data. satisfies enforces exhaustiveness against the union, so a missing handler fails to compile.
"This card has loading ? skeleton : error ? errorView : empty ? emptyState : data ? <Card /> : null and nobody can read it."
Diagnosis: Conditional rendering compressed into one expression.
Refactor:
function CardView ({ state } : { state : ViewState }) {
if (state.kind === "loading" ) return < Skeleton />;
if (state.kind === "error" ) return < ErrorView error = {state.error} />;
if (state.kind === "empty" ) return < EmptyState />;
return < Card data = {state.data} />;
}
Why this works: A discriminated union + early returns reads top-to-bottom. Each branch is independently editable and the union forces you to handle every case.
"The tooltip jumps around on scroll and breaks in tests where document is mocked."
Diagnosis: Imperative DOM access in a React tree. The tooltip reads layout outside React's commit cycle.
Refactor:
"use client" ;
import { useFloating, autoUpdate } from "@floating-ui/react" ;
const { refs , floatingStyles } = useFloating ({ whileElementsMounted: autoUpdate });
< button ref = {refs.setReference}>Help</ button >
< div ref = {refs.setFloating} style = {floatingStyles}>Tooltip</ div >;
Why this works: Refs let the library subscribe to the same lifecycle React already manages. No querySelector, no torn-down nodes, no test breakage.
"We log count inside useEffect(() => { setInterval(() => console.log(count), 1000) }, []) and it always prints 0 even when the UI shows 7."
Diagnosis: Empty deps capture the initial closure. The interval never sees later state.
Refactor:
const countRef = useRef (count);
useEffect (() => { countRef.current = count; }, [count]);
useEffect (() => {
const id = setInterval (() => console. log (countRef.current), 1000 );
return () => clearInterval (id);
}, []);
Why this works: The ref shares an identity across renders so the interval reads the latest value without re-subscribing on every state change.
"Edit the first name field and the full name banner above shows the old value until you blur."
Diagnosis: Derived state stored in useState. Two sources of truth that must be kept in lockstep manually.
Refactor:
const [ firstName , setFirstName ] = useState ( "" );
const [ lastName , setLastName ] = useState ( "" );
const fullName = `${ firstName } ${ lastName }` . trim ();
Why this works: Derived values computed during render are always fresh. There is nothing to keep in sync because there is only one source.
"We wrapped <RowList /> in memo but typing in the parent still re-renders every row."
Diagnosis: The onRowClick callback is recreated on every parent render. A new reference defeats memo.
Refactor:
const handleRowClick = useCallback (( id : string ) => navigate ( `/rows/${ id }` ), [navigate]);
< RowList rows = {rows} onRowClick = {handleRowClick} />;
Why this works: useCallback preserves the function identity across renders. memo's shallow compare now sees the same prop and skips the subtree.
"Three pages show prices as $12.5, $12.50, and USD 12.50. Marketing wants one format."
Diagnosis: Duplicated formatting logic. Each file invented its own.
Refactor:
// lib/format.ts
export const money = ( cents : number , currency = "USD" ) =>
new Intl. NumberFormat ( "en-US" , { style: "currency" , currency }). format (cents / 100 );
export const shortDate = ( iso : string ) =>
new Intl. DateTimeFormat ( "en-US" , { dateStyle: "medium" }). format ( new Date (iso));
Why this works: One module, one rule. Changing the format is one edit, and every page picks it up the next deploy.
"Designer says the spacing is inconsistent. Some cards have style={{ padding: 16 }}, some use a CSS class, and they drift."
Diagnosis: Two styling systems competing. Inline style objects ship per-component CSS-in-JS that can never be deduped.
Refactor:
// before
< div style = {{ padding: 16 , borderRadius: 8 , background: "white" }}>...</ div >
// after
< div className = "rounded-lg bg-white p-4" >...</ div >
Why this works: Tailwind classes resolve at build time and dedupe across the bundle. Spacing tokens become enforceable instead of artisanal.