Clean React Architecture: 7 Refactoring Patterns for Maintainable Component Trees
Transform bloated 800-line React components into modular, testable architectures. Master custom hooks extraction, compound components, polymorphic slots, and the React 19 use() hook.
The 'God Component' Anti-Pattern in Growing Codebases
As software matures, components that began as simple 50-line forms inevitably swell into 800-line monoliths. They juggle API fetching, local state mutations, error validation, modal triggers, analytics tracking, and intricate DOM styling simultaneously.
These 'God Components' become terrifying to touch. Changing a single button style triggers unintended side effects, testing requires mocking dozens of unrelated dependencies, and onboarding new developers slows to a crawl.
Writing clean, resilient React code requires separating business orchestration from presentation. In this guide, we break down 7 practical refactoring patterns used in high-velocity engineering teams.
Refactoring large component trees yields immediate improvements in team velocity, testability, and runtime render efficiency.
Pattern 1: The Container / Presentational Hook Split
Extract all `useState`, `useEffect`, and data-fetching hooks out of the JSX file and into a dedicated custom hook (`useUserProfileData.ts`).
The component file then becomes a pure, deterministic visual renderer that receives clean props and callbacks, making it trivial to test and render inside Storybook previews.
// --- useUserProfile.ts (Pure Logic) ---
export function useUserProfile(userId: string) {
const { data: user, isLoading } = useQuery({ queryKey: ["user", userId], queryFn: () => fetchUser(userId) });
const [isEditing, setIsEditing] = useState(false);
const toggleEdit = () => setIsEditing((prev) => !prev);
return { user, isLoading, isEditing, toggleEdit };
}
// --- UserProfileCard.tsx (Pure Visual Presentation) ---
export function UserProfileCard({ userId }: { userId: string }) {
const { user, isLoading, isEditing, toggleEdit } = useUserProfile(userId);
if (isLoading) return <ProfileSkeleton />;
if (!user) return <EmptyState message="User not found" />;
return (
<div className="rounded-2xl border border-border bg-surface-2 p-6">
<h3 className="text-lg font-bold">{user.name}</h3>
<button onClick={toggleEdit} className="mt-4 text-xs font-mono text-signal">
{isEditing ? "Cancel" : "Edit Profile"}
</button>
</div>
);
}Pattern 2: The Compound Component Pattern for Flexible Primitives
When building complex UI controls like Modals, Accordions, or Select Dropdowns, avoid bloated prop configurations (`<Modal hasCloseButton={true} headerTitle='Settings' isFooterSticky={true} />`).
Use Compound Components with React Context. This allows consuming developers to compose markup naturally: `<Modal><Modal.Header /><Modal.Body /><Modal.Footer /></Modal>`.
Sub-components communicate through internal Context without exposing intermediate state to consuming parents.
import { createContext, useContext, useState, ReactNode } from "react";
const TabContext = createContext<{ activeTab: string; setActiveTab: (id: string) => void } | null>(null);
export function Tabs({ defaultTab, children }: { defaultTab: string; children: ReactNode }) {
const [activeTab, setActiveTab] = useState(defaultTab);
return <TabContext.Provider value={{ activeTab, setActiveTab }}>{children}</TabContext.Provider>;
}
Tabs.Trigger = function TabTrigger({ id, label }: { id: string; label: string }) {
const ctx = useContext(TabContext);
if (!ctx) throw new Error("Tabs.Trigger must be inside Tabs");
const isActive = ctx.activeTab === id;
return (
<button
onClick={() => ctx.setActiveTab(id)}
className={`px-4 py-2 text-sm font-medium ${isActive ? "border-b-2 border-signal text-foreground" : "text-muted-foreground"}`}
>
{label}
</button>
);
};
Tabs.Content = function TabContent({ id, children }: { id: string; children: ReactNode }) {
const ctx = useContext(TabContext);
if (!ctx || ctx.activeTab !== id) return null;
return <div className="p-4">{children}</div>;
};Pattern 3: Polymorphic Components with Radix Slot or the 'as' Prop
Developers frequently need a Button component that can render as a native `<button>`, a TanStack `<Link>`, or an external `<a>` anchor tag without duplicating styles.
Instead of creating three separate components, implement the Radix UI `Slot` pattern or a polymorphic `asChild` prop, delegating rendering to the underlying child element while injecting button styling classes.
import { Slot } from "@radix-ui/react-slot";
import { ButtonHTMLAttributes, forwardRef } from "react";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ asChild = false, className, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
className="inline-flex items-center justify-center rounded-xl bg-signal px-4 py-2 text-sm font-medium text-background hover:bg-signal/90 transition-colors"
{...props}
/>
);
}
);Pattern 4: Eliminating Unnecessary useEffect Synchronization
The most abused hook in React history is `useEffect`. Over 60% of effects are used incorrectly to calculate derived state from props or synchronize form inputs.
If state can be computed from existing props during rendering (`const fullName = firstName + ' ' + lastName`), calculate it inline. If you must respond to user actions, perform the logic inside the user's event handler, never in an asynchronous effect.
Pattern 5: React 19 useActionState and Optimistic Form Submissions
React 19 revolutionizes form mutations with `useActionState` (formerly useFormState). Instead of manually tracking `submitting`, `error`, and `success` booleans, actions return state automatically from asynchronous dispatchers.
Combine `useActionState` with `useOptimistic` to render updated comments or profiles before the network mutation completes, delivering instant feedback without boilerplate.
import { useActionState, useOptimistic } from "react";
async function updateBioAction(previousState: { error?: string }, formData: FormData) {
const bio = formData.get("bio") as string;
const res = await fetch("/api/user/bio", { method: "POST", body: JSON.stringify({ bio }) });
if (!res.ok) return { error: "Failed to update profile bio" };
return { success: true };
}
export function ProfileBioEditor({ initialBio }: { initialBio: string }) {
const [state, formAction, isPending] = useActionState(updateBioAction, {});
const [optimisticBio, setOptimisticBio] = useOptimistic(initialBio, (_, newBio: string) => newBio);
return (
<form action={async (formData) => {
setOptimisticBio(formData.get("bio") as string);
await formAction(formData);
}}>
<p className="text-sm text-foreground/80">{optimisticBio}</p>
<textarea name="bio" defaultValue={initialBio} disabled={isPending} className="border p-2 rounded-lg" />
<button type="submit" disabled={isPending} className="btn-primary">
{isPending ? "Saving..." : "Save Bio"}
</button>
{state.error && <p className="text-red-500 text-xs">{state.error}</p>}
</form>
);
}Pattern 6: Inversion of Control via Render Props for Headless Layouts
When designing reusable list virtualization, table filters, or search comboboxes, avoid hardcoding the visual markup inside the logic container. Hardcoding forces every consumer to accept your exact markup and styling.
Adopt Inversion of Control (IoC). Provide a headless component or custom hook that exposes pure state and event handlers, leaving the consuming developer complete freedom to render whatever JSX elements their user flow demands.
interface HeadlessListProps<T> {
items: T[];
filterKey: keyof T;
children: (props: { filtered: T[]; query: string; setQuery: (q: string) => void }) => React.ReactNode;
}
export function HeadlessSearchList<T>({ items, filterKey, children }: HeadlessListProps<T>) {
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
if (!query) return items;
return items.filter((item) => String(item[filterKey]).toLowerCase().includes(query.toLowerCase()));
}, [items, filterKey, query]);
return <>{children({ filtered, query, setQuery })}</>;
}When does a custom hook become too granular?
If a custom hook is only used by a single component and contains less than 10 lines of simple state, keeping it inline is fine. Extract hooks when state logic exceeds 20 lines or requires unit testing.
Does splitting components into smaller sub-components hurt runtime performance?
No. In fact, smaller components often improve rendering performance because state changes only trigger re-renders of the isolated child sub-tree rather than the entire page.
How do we prevent prop drilling without overusing global state stores?
Use component composition (`children` prop) to pass pre-rendered JSX elements directly to the layout container, eliminating intermediate prop forwarding.