React Component Architecture Patterns That Scale
Battle-tested patterns for building maintainable React applications
Every React codebase starts clean. A few components, simple props, straightforward state. Then features pile up, edge cases multiply, and suddenly you're staring at a 600-line component that fetches data, manages three pieces of state, renders conditionally based on user roles, and has a useEffect that does five different things.
I've refactored this exact component dozens of times across different codebases. The patterns that follow are the ones I reach for every time — not because they're theoretically elegant, but because they consistently produce code that's easier to read, test, and extend.
Pattern 1: Compound Components
Compound components let you build flexible APIs where the parent component manages shared state and the children consume it. Think of <select> and <option> in HTML — they're meaningless alone, but together they form a complete interface.
When to Use
- Components that have multiple sub-parts with shared state
- You want flexible composition without prop drilling
- The consumer needs control over layout and ordering
Implementation
import { createContext, useContext, useState, ReactNode } from 'react';
// Shared context
interface AccordionContextType {
openItems: Set<string>;
toggle: (id: string) => void;
}
const AccordionContext = createContext<AccordionContextType | null>(null);
function useAccordionContext() {
const context = useContext(AccordionContext);
if (!context) {
throw new Error('Accordion components must be used within <Accordion>');
}
return context;
}
// Parent component
interface AccordionProps {
children: ReactNode;
multiple?: boolean;
}
function Accordion({ children, multiple = false }: AccordionProps) {
const [openItems, setOpenItems] = useState<Set<string>>(new Set());
const toggle = (id: string) => {
setOpenItems((prev) => {
const next = new Set(multiple ? prev : []);
if (prev.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
return (
<AccordionContext.Provider value={{ openItems, toggle }}>
<div className="divide-y">{children}</div>
</AccordionContext.Provider>
);
}
// Child components
function AccordionItem({ id, children }: { id: string; children: ReactNode }) {
return <div>{children}</div>;
}
function AccordionTrigger({ id, children }: { id: string; children: ReactNode }) {
const { openItems, toggle } = useAccordionContext();
return (
<button
onClick={() => toggle(id)}
aria-expanded={openItems.has(id)}
className="flex w-full items-center justify-between py-4"
>
{children}
<ChevronIcon className={openItems.has(id) ? 'rotate-180' : ''} />
</button>
);
}
function AccordionContent({ id, children }: { id: string; children: ReactNode }) {
const { openItems } = useAccordionContext();
if (!openItems.has(id)) return null;
return <div className="pb-4">{children}</div>;
}
// Attach sub-components
Accordion.Item = AccordionItem;
Accordion.Trigger = AccordionTrigger;
Accordion.Content = AccordionContent;
Usage
<Accordion multiple>
<Accordion.Item id="1">
<Accordion.Trigger id="1">What is your return policy?</Accordion.Trigger>
<Accordion.Content id="1">
You can return items within 30 days of purchase.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item id="2">
<Accordion.Trigger id="2">Do you ship internationally?</Accordion.Trigger>
<Accordion.Content id="2">
Yes, we ship to over 50 countries.
</Accordion.Content>
</Accordion.Item>
</Accordion>
The consumer has full control over layout and content, while the Accordion manages all the open/close logic internally. This is exactly how libraries like Radix UI and shadcn/ui work under the hood.
Pattern 2: Custom Hooks for Logic Extraction
The most impactful pattern for code organization in modern React. If your component has logic that can be described as "managing X" — extract it into a hook.
The Rule of One
Each custom hook should manage one concern. Not "one thing" in a vague sense — one specific concern with a clear name.
// Bad: One hook doing too many things
function useProjectPage() {
const [projects, setProjects] = useState([]);
const [filter, setFilter] = useState('');
const [sortBy, setSortBy] = useState('date');
const [isCreating, setIsCreating] = useState(false);
const [newProject, setNewProject] = useState({});
// ... 100 more lines
}
// Good: Separate hooks for separate concerns
function useProjects() {
return useQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
});
}
function useProjectFilters() {
const [filter, setFilter] = useState('');
const [sortBy, setSortBy] = useState<SortField>('date');
// ... filter/sort logic
return { filter, setFilter, sortBy, setSortBy, applyFilters };
}
function useCreateProject() {
return useMutation({
mutationFn: createProject,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] }),
});
}
The Component Becomes Simple
function ProjectsPage() {
const { data: projects, isLoading } = useProjects();
const { filter, setFilter, sortBy, setSortBy, applyFilters } = useProjectFilters();
const createProject = useCreateProject();
const filteredProjects = applyFilters(projects ?? []);
if (isLoading) return <ProjectsSkeleton />;
return (
<PageLayout>
<ProjectToolbar
filter={filter}
onFilterChange={setFilter}
sortBy={sortBy}
onSortChange={setSortBy}
onCreateClick={() => createProject.mutate(defaultProject)}
/>
<ProjectGrid projects={filteredProjects} />
</PageLayout>
);
}
Every piece of logic has a home. The component is just composition.
Pattern 3: Container / Presenter (Modernized)
The classic container/presenter pattern got a bad reputation because of HOCs and class components. But the core idea — separating data logic from rendering — is timeless. With hooks, the implementation is clean.
// Presenter: Pure rendering, easily testable
interface ProjectCardProps {
project: Project;
onEdit: () => void;
onDelete: () => void;
isDeleting: boolean;
}
function ProjectCard({ project, onEdit, onDelete, isDeleting }: ProjectCardProps) {
return (
<Card>
<CardHeader>
<CardTitle>{project.name}</CardTitle>
<Badge variant={project.status === 'active' ? 'default' : 'secondary'}>
{project.status}
</Badge>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">{project.description}</p>
</CardContent>
<CardFooter className="gap-2">
<Button variant="outline" onClick={onEdit}>Edit</Button>
<Button
variant="destructive"
onClick={onDelete}
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
</CardFooter>
</Card>
);
}
// Container: Handles data and logic
function ProjectCardContainer({ projectId }: { projectId: string }) {
const { data: project } = useProject(projectId);
const deleteProject = useDeleteProject();
const navigate = useNavigate();
if (!project) return <ProjectCardSkeleton />;
return (
<ProjectCard
project={project}
onEdit={() => navigate(`/projects/${projectId}/edit`)}
onDelete={() => deleteProject.mutate(projectId)}
isDeleting={deleteProject.isPending}
/>
);
}
The presenter is trivially testable — pass props, check output. The container handles all the messy real-world stuff (data fetching, navigation, mutations).
Pattern 4: Feature-Based Folder Structure
As applications grow, organizing by file type (components/, hooks/, utils/) creates folders with dozens of unrelated files. Feature-based organization keeps related code together.
src/
├── features/
│ ├── projects/
│ │ ├── components/
│ │ │ ├── ProjectCard.tsx
│ │ │ ├── ProjectGrid.tsx
│ │ │ ├── ProjectToolbar.tsx
│ │ │ └── CreateProjectDialog.tsx
│ │ ├── hooks/
│ │ │ ├── useProjects.ts
│ │ │ ├── useCreateProject.ts
│ │ │ └── useProjectFilters.ts
│ │ ├── types.ts
│ │ ├── api.ts
│ │ └── index.ts # Public API for this feature
│ ├── auth/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── types.ts
│ │ └── index.ts
│ └── settings/
│ ├── components/
│ ├── hooks/
│ └── index.ts
├── components/
│ └── ui/ # Shared UI components (shadcn, etc.)
├── hooks/ # Shared hooks (useMediaQuery, etc.)
├── lib/ # Shared utilities
└── types/ # Shared types
The Index File Pattern
Each feature exports a clean public API through its index.ts:
// features/projects/index.ts
export { ProjectCard } from './components/ProjectCard';
export { ProjectGrid } from './components/ProjectGrid';
export { useProjects } from './hooks/useProjects';
export type { Project, ProjectStatus } from './types';
Other features import from the public API, not from internal paths:
// Good
import { ProjectCard } from '@/features/projects';
// Bad - reaching into internal structure
import { ProjectCard } from '@/features/projects/components/ProjectCard';
This creates clear boundaries between features and makes refactoring internal structure safe.
Pattern 5: State Colocation
State should live as close as possible to where it's used. This is simple in principle but requires discipline in practice.
The Hierarchy
- Component state (
useState): UI state that only this component cares about - Lifted state: State shared between sibling components, lifted to their common parent
- Feature state (Zustand store or Context): State shared across a feature's components
- Global state (Zustand store): State needed by many features (user, theme, notifications)
- URL state (
useSearchParams): State that should survive page refreshes and be shareable (filters, pagination, tabs) - Server state (TanStack Query): Data from your API
function ProjectsPage() {
// URL state - survives refresh, shareable via URL
const [searchParams, setSearchParams] = useSearchParams();
const currentTab = searchParams.get('tab') ?? 'active';
// Server state - managed by TanStack Query
const { data: projects } = useProjects({ status: currentTab });
// Local UI state - only this component cares
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
return (
<PageLayout>
<Tabs value={currentTab} onValueChange={(tab) => {
setSearchParams({ tab });
}}>
<TabsList>
<TabsTrigger value="active">Active</TabsTrigger>
<TabsTrigger value="archived">Archived</TabsTrigger>
</TabsList>
</Tabs>
<ProjectGrid projects={projects ?? []} />
<CreateProjectDialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
/>
</PageLayout>
);
}
Notice: the active tab is in the URL (because it should be bookmarkable), the dialog state is local (no one else cares if it's open), and the project data is in TanStack Query (it's server state).
Pattern 6: Performance Patterns
Performance optimization in React comes down to one thing: preventing unnecessary re-renders. Here are the patterns that matter.
Memoize Expensive Computations
function AnalyticsDashboard({ data }: { data: DataPoint[] }) {
// This runs on every render - if data is large, it's slow
// const chartData = transformDataForChart(data);
// This only recalculates when data changes
const chartData = useMemo(() => transformDataForChart(data), [data]);
return <Chart data={chartData} />;
}
Stabilize Callback References
function ProjectList({ projects }: { projects: Project[] }) {
// Bad: New function on every render → children re-render
// const handleDelete = (id: string) => deleteProject(id);
// Good: Stable reference
const handleDelete = useCallback((id: string) => {
deleteProject(id);
}, [deleteProject]);
return projects.map((p) => (
<ProjectCard key={p.id} project={p} onDelete={handleDelete} />
));
}
Component Splitting for Re-Render Isolation
// Bad: The entire component re-renders when the timer ticks
function Dashboard() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const interval = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(interval);
}, []);
return (
<div>
<Clock time={time} /> {/* Updates every second */}
<ExpensiveChart data={data} /> {/* Re-renders every second too! */}
<ProjectList projects={projects} /> {/* And this too! */}
</div>
);
}
// Good: Isolate the frequently-updating part
function Dashboard() {
return (
<div>
<LiveClock /> {/* Only this re-renders every second */}
<ExpensiveChart data={data} />
<ProjectList projects={projects} />
</div>
);
}
function LiveClock() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const interval = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(interval);
}, []);
return <Clock time={time} />;
}
Virtualize Long Lists
If you're rendering more than 50-100 items in a list, virtualize it:
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualProjectList({ projects }: { projects: Project[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: projects.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80,
});
return (
<div ref={parentRef} className="h-[600px] overflow-auto">
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: virtualRow.start,
height: virtualRow.size,
width: '100%',
}}
>
<ProjectCard project={projects[virtualRow.index]} />
</div>
))}
</div>
</div>
);
}
Putting It All Together
These patterns aren't meant to be used in isolation. Here's how they combine in a real feature:
features/projects/
├── components/
│ ├── ProjectCard.tsx # Presenter pattern
│ ├── ProjectGrid.tsx # Virtualized list
│ ├── ProjectToolbar.tsx # Compound component for filters
│ └── CreateProjectDialog.tsx # Compound component
├── hooks/
│ ├── useProjects.ts # Server state (TanStack Query)
│ ├── useCreateProject.ts # Mutation hook
│ └── useProjectFilters.ts # Client state (URL params)
├── containers/
│ └── ProjectCardContainer.tsx # Container component
├── types.ts
├── api.ts
└── index.ts # Public API
The component at the top of the tree is simple composition:
export function ProjectsPage() {
const { data: projects, isLoading } = useProjects();
const filters = useProjectFilters();
if (isLoading) return <ProjectsPageSkeleton />;
return (
<DashboardLayout>
<PageHeader title="Projects" action={<CreateProjectDialog />} />
<ProjectToolbar filters={filters} />
<ProjectGrid projects={filters.apply(projects ?? [])} />
</DashboardLayout>
);
}
Clean, readable, and every piece has a single responsibility. When you need to change how projects are fetched, you go to useProjects. When you need to change how they're displayed, you go to ProjectCard. When you need to add a new filter, you extend useProjectFilters.
That's architecture that scales — not because it's complex, but because it's organized.