Advanced React Patterns for Scalable Applications
Development9 min read

Advanced React Patterns for Scalable Applications

Vighnesh Salunkhe

Vighnesh Salunkhe

Full Stack Developer

Published

April 22, 2026

Advanced React Patterns for Scalable Applications

Advanced React Patterns for Scalable Applications

Every React developer hits the same wall eventually. The component that started as 50 lines is now 400. Props are drilling five levels deep. State is scattered everywhere. The codebase works, but nobody wants to touch it.

The solution is not a new framework — it is learning the patterns that make React scale. These are the same patterns powering Radix UI, Shadcn, React Hook Form, and every other library you rely on daily.

"Keep your components focused on how things look, and your hooks focused on how things work."


1. Compound Components: The API Design Pattern

Compound components let you build a group of components that share implicit state through context. The parent manages state; the children consume it. The consumer gets a clean, expressive API.

This is exactly how

text
<select>
and
text
<option>
work in HTML — and how Radix UI, Headless UI, and Shadcn implement every interactive component.

The Problem Without It

text
// BAD: Massive prop surface, impossible to extend
<Tabs
  items={[
    { label: 'Profile', content: <ProfilePanel /> },
    { label: 'Settings', content: <SettingsPanel /> },
  ]}
  defaultTab={0}
  onTabChange={handleChange}
  tabClassName="..."
  contentClassName="..."
  activeTabClassName="..."
  disabledTabs={[1]}
/>

The Compound Component Solution

text
import React, { createContext, useContext, useState } from 'react';

// 1. Create shared context
interface TabsContextValue {
  activeTab: string;
  setActiveTab: (id: string) => void;
}
const TabsContext = createContext<TabsContextValue | null>(null);

const useTabs = () => {
  const ctx = useContext(TabsContext);
  if (!ctx) throw new Error('useTabs must be used within <Tabs>');
  return ctx;
};

// 2. Parent manages state, exposes via context
function Tabs({ children, defaultTab }: { children: React.ReactNode; defaultTab: string }) {
  const [activeTab, setActiveTab] = useState(defaultTab);
  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      <div className="w-full">{children}</div>
    </TabsContext.Provider>
  );
}

// 3. Children consume context — no prop drilling
function TabList({ children }: { children: React.ReactNode }) {
  return <div className="flex gap-1 border-b border-white/10 mb-6">{children}</div>;
}

function Tab({ id, children }: { id: string; children: React.ReactNode }) {
  const { activeTab, setActiveTab } = useTabs();
  const isActive = activeTab === id;
  return (
    <button
      onClick={() => setActiveTab(id)}
      className={`px-4 py-2 text-sm font-semibold rounded-t-lg transition-colors ${
        isActive
          ? 'text-white border-b-2 border-purple-500'
          : 'text-gray-400 hover:text-white'
      }`}
    >
      {children}
    </button>
  );
}

function TabPanel({ id, children }: { id: string; children: React.ReactNode }) {
  const { activeTab } = useTabs();
  if (activeTab !== id) return null;
  return <div className="animate-in fade-in duration-200">{children}</div>;
}

// 4. Attach sub-components as static properties
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;

// 5. Consumer gets a clean, readable API
export default function ProfilePage() {
  return (
    <Tabs defaultTab="profile">
      <Tabs.List>
        <Tabs.Tab id="profile">Profile</Tabs.Tab>
        <Tabs.Tab id="settings">Settings</Tabs.Tab>
        <Tabs.Tab id="billing">Billing</Tabs.Tab>
      </Tabs.List>
      <Tabs.Panel id="profile"><ProfilePanel /></Tabs.Panel>
      <Tabs.Panel id="settings"><SettingsPanel /></Tabs.Panel>
      <Tabs.Panel id="billing"><BillingPanel /></Tabs.Panel>
    </Tabs>
  );
}
The consumer never needs to know how state is managed. You can completely refactor the internals without changing the API. That is the power of this pattern.

2. Custom Hooks: Separating Logic from UI

The single most impactful pattern in modern React. A custom hook extracts stateful logic into a reusable function, leaving the component focused purely on rendering.

Data Fetching Hook

text
import { useState, useEffect, useCallback, useRef } from 'react';

interface FetchState<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
  refetch: () => void;
}

function useFetch<T>(url: string): FetchState<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  const abortRef = useRef<AbortController | null>(null);

  const fetchData = useCallback(async () => {
    // Cancel any in-flight request
    abortRef.current?.abort();
    abortRef.current = new AbortController();

    setLoading(true);
    setError(null);

    try {
      const res = await fetch(url, { signal: abortRef.current.signal });
      if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
      const json = await res.json();
      setData(json);
    } catch (err) {
      if ((err as Error).name !== 'AbortError') {
        setError(err as Error);
      }
    } finally {
      setLoading(false);
    }
  }, [url]);

  useEffect(() => {
    fetchData();
    return () => abortRef.current?.abort();
  }, [fetchData]);

  return { data, loading, error, refetch: fetchData };
}

// Component is now trivially simple
function UserProfile({ userId }: { userId: string }) {
  const { data: user, loading, error, refetch } = useFetch<User>(`/api/users/${userId}`);

  if (loading) return <ProfileSkeleton />;
  if (error) return <ErrorState message={error.message} onRetry={refetch} />;
  if (!user) return null;

  return <div className="p-6"><h2>{user.name}</h2><p>{user.email}</p></div>;
}

Debounced Search Hook

text
import { useState, useEffect } from 'react';

function useDebounce<T>(value: T, delay: number): T {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debounced;
}

// Usage: search only fires after user stops typing for 300ms
function SearchBar() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);
  const { data: results } = useFetch<Result[]>(
    debouncedQuery ? `/api/search?q=${encodeURIComponent(debouncedQuery)}` : ''
  );

  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="Search..."
        className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 outline-none focus:border-purple-500"
      />
      {results?.map(r => <ResultItem key={r.id} result={r} />)}
    </div>
  );
}

3. Render Props: Logic Without Coupling

Render props pass a function as a prop, letting the parent control logic while the consumer controls rendering. Less common since hooks, but still the right tool for certain problems — especially when you need to share logic between class and function components, or expose imperative handles.

text
// A mouse tracker that doesn't care how its data is displayed
interface MousePosition { x: number; y: number }

function MouseTracker({ render }: { render: (pos: MousePosition) => React.ReactNode }) {
  const [position, setPosition] = useState<MousePosition>({ x: 0, y: 0 });

  return (
    <div
      className="w-full h-64 relative"
      onMouseMove={e => setPosition({ x: e.clientX, y: e.clientY })}
    >
      {render(position)}
    </div>
  );
}

// Consumer decides what to render with the data
<MouseTracker
  render={({ x, y }) => (
    <div
      className="absolute w-4 h-4 rounded-full bg-purple-500 pointer-events-none -translate-x-1/2 -translate-y-1/2"
      style={{ left: x, top: y }}
    />
  )}
/>
React component architecture visualization
React component architecture visualization

4. Higher-Order Components: Cross-Cutting Concerns

HOCs wrap a component and inject behavior. They are the right pattern for concerns that span many components — auth guards, analytics tracking, error boundaries, feature flags.

text
import { ComponentType, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/hooks/useAuth';

// Auth guard HOC
function withAuth<P extends object>(
  WrappedComponent: ComponentType<P>,
  options: { redirectTo?: string; requiredRole?: string } = {}
) {
  const { redirectTo = '/login', requiredRole } = options;

  function AuthGuard(props: P) {
    const { user, loading } = useAuth();
    const router = useRouter();

    useEffect(() => {
      if (!loading && !user) router.replace(redirectTo);
      if (!loading && requiredRole && user?.role !== requiredRole) {
        router.replace('/unauthorized');
      }
    }, [user, loading, router]);

    if (loading) return <FullPageSpinner />;
    if (!user) return null;

    return <WrappedComponent {...props} />;
  }

  AuthGuard.displayName = `withAuth(${WrappedComponent.displayName ?? WrappedComponent.name})`;
  return AuthGuard;
}

// Analytics HOC — tracks page views automatically
function withAnalytics<P extends object>(
  WrappedComponent: ComponentType<P>,
  pageName: string
) {
  function TrackedComponent(props: P) {
    useEffect(() => {
      analytics.track('page_view', { page: pageName, timestamp: Date.now() });
    }, []);

    return <WrappedComponent {...props} />;
  }

  TrackedComponent.displayName = `withAnalytics(${WrappedComponent.name})`;
  return TrackedComponent;
}

// Compose multiple HOCs cleanly
const AdminDashboard = withAnalytics(
  withAuth(DashboardContent, { requiredRole: 'admin' }),
  'admin_dashboard'
);
Always set
text
displayName
on HOC-wrapped components. Without it, React DevTools shows a wall of anonymous components and debugging becomes painful.

5. useMemo and useCallback: When They Actually Help

These are the most misused hooks in React. Most developers add them everywhere "for performance." That is wrong — they have a cost (memory + comparison overhead) and only pay off in specific situations.

text
// When useMemo is WORTH IT: expensive computation
function DataGrid({ rows, filters }: { rows: Row[]; filters: Filter[] }) {
  // This runs on every render without useMemo
  // With 10,000 rows and complex filters, that's expensive
  const filteredRows = useMemo(
    () => rows.filter(row => filters.every(f => f.test(row))),
    [rows, filters] // Only recompute when rows or filters change
  );

  return <VirtualList items={filteredRows} />;
}

// When useCallback is WORTH IT: stable reference for child that uses React.memo
const MemoizedChild = React.memo(function Child({
  onAction
}: {
  onAction: () => void
}) {
  return <button onClick={onAction}>Action</button>;
});

function Parent() {
  const [count, setCount] = useState(0);

  // Without useCallback: new function reference every render → MemoizedChild re-renders anyway
  // With useCallback: stable reference → MemoizedChild only re-renders when deps change
  const handleAction = useCallback(() => {
    console.log('action triggered');
  }, []); // No deps = stable forever

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <MemoizedChild onAction={handleAction} />
    </div>
  );
}
text
useMemo
and
text
useCallback
without
text
React.memo
on the child are almost always pointless. The child re-renders regardless of whether the prop reference is stable. Profile first, optimize second.

6. The State Reducer Pattern

Give consumers control over how state updates work — without exposing the full state machine. Used by React Hook Form, Downshift, and other headless libraries.

text
type Action =
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'RESET' };

interface CounterState { count: number }

function defaultReducer(state: CounterState, action: Action): CounterState {
  switch (action.type) {
    case 'INCREMENT': return { count: state.count + 1 };
    case 'DECREMENT': return { count: state.count - 1 };
    case 'RESET':     return { count: 0 };
    default:          return state;
  }
}

function useCounter(
  initialCount = 0,
  // Consumer can override how any action is handled
  reducer: (state: CounterState, action: Action) => CounterState = defaultReducer
) {
  const [state, dispatch] = useReducer(reducer, { count: initialCount });
  return {
    count: state.count,
    increment: () => dispatch({ type: 'INCREMENT' }),
    decrement: () => dispatch({ type: 'DECREMENT' }),
    reset:     () => dispatch({ type: 'RESET' }),
  };
}

// Consumer can cap the counter at 10 without touching the hook internals
function CappedCounter() {
  const { count, increment, decrement } = useCounter(0, (state, action) => {
    const next = defaultReducer(state, action);
    return { count: Math.min(Math.max(next.count, 0), 10) }; // clamp 0-10
  });

  return (
    <div className="flex items-center gap-4">
      <button onClick={decrement} className="px-4 py-2 rounded-xl bg-white/10">-</button>
      <span className="text-2xl font-bold w-8 text-center">{count}</span>
      <button onClick={increment} className="px-4 py-2 rounded-xl bg-white/10">+</button>
    </div>
  );
}

7. Watch: React Patterns in Depth

Video thumbnail
Watch on YouTube

Pattern Decision Guide

PatternUse when
Compound ComponentsBuilding UI kits, shared state between sibling components
Custom HooksReusing stateful logic across multiple components
Render PropsSharing logic when the consumer controls rendering
HOCCross-cutting concerns: auth, analytics, error boundaries
State ReducerGiving consumers control over state transitions
useMemo / useCallbackExpensive computations or stable refs for memoized children

Mastering these patterns does not mean using all of them everywhere. It means knowing which tool fits the problem — and reaching for it confidently when the time comes.

#React#Design Patterns#JavaScript#Software Architecture
Vighnesh Salunkhe
Written by

Vighnesh Salunkhe

"Passionate about building scalable web applications and exploring the intersection of AI and human creativity."

Join the Conversation

Share your thoughts or ask a question

Share this article