Refactoring Example Scenarios
Twenty real-world refactor requests as they actually arrive - some as PR review comments, some as stakeholder complaints that turn out to be code-shape problems, some as cleanup tasks from a tech lead. Each entry shows the request as written, the diagnosis of what is really wrong, a concise refactor, and why the new shape is better.
How to Use This List
- 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.
1. "This component is 800 lines and nobody wants to touch it"
"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.
2. "We're passing user through five components just to read it in a button"
"The
currentUserprop 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.
3. "The product page fetches data inside useEffect and flashes a spinner every navigation"
"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.
4. "Our entire dashboard is marked \"use client\" and the JS bundle is 600 KB"
"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.
5. "There are hardcoded 'admin' and 'editor' strings everywhere and rename broke prod"
"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.
6. "TypeScript is full of any and we keep shipping undefined is not a function"
"Errors in Sentry mention
Cannot read properties of undefined. The fetch result is typedanyand we accesseddata.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.
7. "The <Button /> has 11 boolean props and the calls are unreadable"
"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.
8. "The createInvoice function takes 9 positional arguments"
"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.
9. "Five components have nearly identical useEffect blocks fetching the same endpoint"
"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.
10. "Our signup form has 200 lines of state and validation logic in the component"
"The signup form keeps growing as we add fields. Validation rules live in a giant
ifladder 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.
11. "There's still one class component and it uses componentWillReceiveProps"
"ESLint warns on
UNSAFE_componentWillReceivePropsin<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.
12. "After deleting a row the wrong item shows highlighted"
"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.
13. "We have a 70-case switch mapping event types to handlers"
"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.
14. "JSX has triple-nested ternaries and reviewers refuse to approve"
"This card has
loading ? skeleton : error ? errorView : empty ? emptyState : data ? <Card /> : nulland 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.
15. "A custom tooltip uses document.querySelector to position itself"
"The tooltip jumps around on scroll and breaks in tests where
documentis 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.
16. "An effect prints stale values - the count keeps logging '0'"
"We log
countinsideuseEffect(() => { 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.
17. "We store fullName in state and it goes out of sync with firstName/lastName"
"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.
18. "Our memoized list re-renders every keystroke in the search box"
"We wrapped
<RowList />inmemobut 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.
19. "Every component formats prices and dates a slightly different way"
"Three pages show prices as
$12.5,$12.50, andUSD 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.
20. "Half the components use inline style={{}} and the bundle has 30 KB of duplicated CSS"
"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.
Related
- UI Refactor Checklist - the systematic walkthrough that complements these bite-sized examples.
- Refactoring Decisions - the 30 high-leverage refactor decisions this list draws from.
- State & Re-render Bug Scenarios - bug-side counterparts to refactor items 16-18.