Sheetal Sindhu

Jun 28, 2025 • 11 min read

React Design Patterns

Guide for 2025 developers

React Design Patterns

I’ve seen how React apps can grow complex – especially in a micro-frontend architecture using React + TypeScript by 2025. In this guide, we’ll look at practical React design patterns that keep code organized and scalable.

Micro-Frontend Context and TypeScript

In modern frontends, we often break a large app into micro-frontends – independent React apps integrated at runtime. This lets teams work in parallel and scale features independently. In such setups, clear component boundaries and shared data conventions (e.g. via context providers) are crucial.

Using TypeScript throughout adds type safety to patterns like context or hooks, catching errors early. For example, a shared UserContext might export typed state and actions interfaces, ensuring all micro-apps use it consistently. Always define Prop types or FC<Props> so your components' contracts are explicit.

Real-world insight: We once refactored a huge dashboard into micro-frontends by feature domains (analytics, user management, etc.). Defining shared design patterns up-front (and shared UI library components) made integration smoother. Using consistent patterns across teams helped new devs ramp up faster.

Container-Presenter Pattern

The Container/Presenter (or “Smart/Dumb”) pattern splits a component into two parts: a container (handles data/state) and a presentational component (renders UI). The container might fetch data, manage state or Redux, while the presenter just shows props. This separation enforces single responsibility – UI vs logic.

// DogList.tsx (Presentational)
type Dog = { id: string, url: string };
export const DogList: React.FC<{ dogs: Dog[] }> = ({ dogs }) => (
  <div>
    {dogs.map(d => <img src={d.url} key={d.id} alt="A dog" />)}
  </div>
);

// DogListContainer.tsx (Container)
export const DogListContainer: React.FC = () => {
  const [dogs, setDogs] = useState<Dog[]>([]);
  useEffect(() => {
    fetch('/api/dogs').then(res => res.json()).then((data: Dog[]) => setDogs(data));
  }, []);
  return <DogList dogs={dogs} />;
};

Notice how DogList just takes a dogs prop and renders it. All fetching/state was in DogListContainer. This makes DogList easier to reuse and test. (Indeed, presentational components become pure functions of props.)

  • Code Review Tip: Ensure presentational components remain stateless and without side-effects. In review, check that UI components only render data passed via props, and that all useState/useEffect logic lives in containers or custom hooks.

  • Common Pitfall: Overcomplicating small components with a container. If a component is simple, using a custom hook instead of a separate file may suffice (as React docs note, Hooks let you “achieve the same result without having to use [this] pattern” for small cases). Avoid making a two-file split when a single component with hooks is clean enough.


Compound Components Pattern

The Compound Components pattern lets multiple sub-components share implicit state via a parent (often using Context or cloning). It’s common in component libraries (e.g. <Tabs>, <Dropdown>, <Flyout>). The parent holds state and provides it, while children access it without prop drilling.

For example, a simple tabs component:

// Tabs.tsx
interface TabsContextType { activeTab: string; setActive: (id: string) => void; }
const TabsContext = createContext<TabsContextType | null>(null);

export const Tabs: React.FC = ({ children }) => {
  const [activeTab, setActiveTab] = useState<string>('tab1');
  return (
    <TabsContext.Provider value={{ activeTab, setActive: setActiveTab }}>
      {children}
    </TabsContext.Provider>
  );
};

export const TabList: React.FC = ({ children }) => <div className="tab-list">{children}</div>;

export const Tab: React.FC<{ id: string }> = ({ id, children }) => {
  const ctx = useContext(TabsContext)!;
  const isActive = ctx.activeTab === id;
  return (
    <button onClick={() => ctx.setActive(id)} className={isActive ? 'active' : ''}>
      {children}
    </button>
  );
};

export const TabPanels: React.FC = ({ children }) => <div className="panels">{children}</div>;

export const TabPanel: React.FC<{ id: string }> = ({ id, children }) => {
  const ctx = useContext(TabsContext)!;
  return ctx.activeTab === id ? <div>{children}</div> : null;
};

// Usage:
<Tabs>
  <TabList>
    <Tab id="tab1">Tab 1</Tab>
    <Tab id="tab2">Tab 2</Tab>
  </TabList>
  <TabPanels>
    <TabPanel id="tab1">Content for Tab 1</TabPanel>
    <TabPanel id="tab2">Content for Tab 2</TabPanel>
  </TabPanels>
</Tabs>

Here Tabs provides context; its children (Tab, TabPanel) read it. No prop drilling is needed. This “compound” pattern creates a declarative API – you just nest the pieces, and they automatically share state. It’s powerful for building reusable UI components (LogRocket notes it’s great for dropdowns, accordions, etc.).

  • Code Review Tip: Check that compound children are used under the correct parent. (E.g. a <Tab> outside <Tabs> would have no context and break.) Also confirm you’ve set up Context or prop-cloning correctly.

  • Common Pitfall: Forgetting to forward props or context. If using a library like Radix or your own asChild approach (Slot pattern, see below), ensure you use React.cloneElement or a Slot so child props merge correctly. Also avoid unnecessary nesting – compounds require specific structure, so wrapping one <Tab> in an extra <div> might block context.


Hooks Composition Pattern

Modern React encourages custom hooks. But an even more powerful pattern is composing hooks: building small hooks and combining them to encapsulate complex logic. This keeps components slim.

For instance:

function useUserData(userId: string) {
  const [user, setUser] = useState<User | null>(null);
  useEffect(() => {
    fetch(`/api/users/${userId}`).then(res => res.json()).then(setUser);
  }, [userId]);
  return user;
}

function useUserPermissions(userId: string) {
  const [perms, setPerms] = useState<string[]>([]);
  useEffect(() => {
    fetch(`/api/users/${userId}/perms`).then(res => res.json()).then(setPerms);
  }, [userId]);
  return perms;
}

// Compose hooks into a higher-level hook:
function useUserProfile(userId: string) {
  const user = useUserData(userId);
  const permissions = useUserPermissions(userId);
  return { user, permissions };
}

// Then in a component:
const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
  const { user, permissions } = useUserProfile(userId);
  if (!user) return <div>Loading...</div>;
  return (
    <div>
      <h2>{user.name}</h2>
      <ul>{permissions.map(p => <li key={p}>{p}</li>)}</ul>
    </div>
  );
};

Here, useUserProfile composes useUserData and useUserPermissions. We reuse the fetching logic and keep component code focused. As AngularMinds explains, “Custom Hooks composition involves combining multiple custom Hooks to share logic, encapsulate complex behavior, and promote reusability”.

  • Code Review Tip: Verify each custom hook is small and singular in purpose. A hook should do one thing (e.g. data fetch, pagination, form state). When reviewing, ensure hooks have proper dependency arrays and do not call hooks conditionally (always follow Hook rules).

  • Common Pitfall: Missing dependencies in useEffect inside custom hooks can cause stale data. Also watch out for mixing stateful logic and UI: the hook should return data/state, not JSX. In TypeScript, define clear types for hook return values to catch mistakes.


Provider Pattern (Context API)

When many components need the same data (e.g. auth info, theme, language), the Provider pattern via React Context is ideal. Instead of passing props down, you wrap parts of the app with a Provider component that holds state.

interface ThemeContextType { theme: string; setTheme: (t: string) => void; }
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

export const ThemeProvider: React.FC = ({ children }) => {
  const [theme, setTheme] = useState<'light'|'dark'>('light');
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

// Usage in main app:
<ThemeProvider>
  <App />
</ThemeProvider>;

// Consuming in a component:
const useTheme = () => useContext(ThemeContext)!;
const ThemeSwitcher: React.FC = () => {
  const { theme, setTheme } = useTheme();
  return <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
    Switch to {theme === 'light' ? 'dark' : 'light'}
  </button>;
};

This “Provider Pattern” simplifies state sharing. As Vitor Britto notes, it “leverages React’s context API to manage and pass data through your component tree, avoiding prop drilling”.

  • Code Review Tip: Ensure you’re not putting too much in one context. Britto warns that “all components that consume the context will re-render when the context value changes,” so it’s wise to split unrelated data into multiple context providers. For example, separate AuthProvider, ThemeProvider, etc. This limits unnecessary renders.

  • Common Pitfall: Passing large objects/functions as context value without useMemo/useCallback can cause performance issues. Also check in review that context defaults (when you create it) are correct and used (e.g. undefined vs an object). In TypeScript, don’t forget to type your context and provider props to prevent misuse.


Slot Pattern (asChild / Radix Slot)

A newer composition pattern (made popular by libraries like Radix UI) is the Slot or asChild pattern. It lets you render a custom child element while preserving behavior and props from the parent. This removes unnecessary wrapper DOM nodes and improves semantics.

For example, a Button component with asChild support:

import { Slot } from '@radix-ui/react-slot';

type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean };
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ asChild, children, ...props }, ref) => {
    const Comp: any = asChild ? Slot : 'button';
    return <Comp ref={ref} {...props}>{children}</Comp>;
  }
);

Now you can use it with any tag:

<Button onClick={…}>Normal Button</Button>
<Button asChild>
  <a href="/dashboard" className="custom">
    Go to Dashboard
  </a>
</Button>

In the second case, Button renders as an <a> with button styling/behavior, not an extra <button><a>...</a></button> wrapper. As the creator Jagadhiswaran explains, this pattern eliminates wrapper elements, improves semantic correctness, and keeps event handlers, roles, refs, etc. intact.

  • Code Review Tip: When you use asChild, make sure the child is a single valid React element (not a fragment or multiple children). Always use forwardRef on your component, so the ref propagates correctly through Slot.

  • Common Pitfall: Forgetting to include ref or spreading props correctly will break this pattern. A child’s own props (like its className or onClick) will override the parent’s if they conflict, so be intentional about merging props (Radix’s Slot uses cloneElement under the hood).


Server Components (React 18+)

React’s Server Components (RSC) fundamentally change how we fetch and render data. Server Components run on the server, fetch data, and send HTML to the client – no client JS needed for that component’s output. This can dramatically improve load times and reduce client bundle size. In practice, you might have a server component for heavy data fetching and a client component for interactivity.

For example, in Next.js (2024+), any component file without "use client" at the top is a server component by default. It can fetch data directly:

// PostList.server.tsx (Server Component)
export default function PostList() {
  const posts = fetch('https://api.example.com/posts').then(res => res.json());
  return (
    <ul>
      {posts.map((p: any) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}

No hooks or effects are needed; React handles streaming the HTML. As the Contentful blog notes, RSC “use server-side rendering to optimize the rendering process and only send the necessary data to the client… avoid[ing] hydration”. On the client, you can import this component and it will render instantly without extra JS.

  • Code Review Tip: Distinguish server vs client components. Any UI with interactivity (useState, event handlers) must have "use client". If you forget this, React will fail. Review that server components contain only serializable data logic (no browser APIs).

  • Common Pitfall: Don’t accidentally fetch in both server and client for the same data. If you move a data-fetching container into a server component, remove its client fetch. Also watch the serialization boundary: only data returned from a server component is sent. Unused code branches stay on server – use that to slim down what goes to clients.


Signals (React 19+)

Looking ahead, React may adopt Signals (inspired by SolidJS/Angular) for state. Signals are reactive primitives that trigger fine-grained updates only when used values change. They are not tied to the component lifecycle, so they avoid extra renders. In practice, you can use the Preact Signals library today in React:

import { signal } from '@preact/signals-react';

const count = signal(0);

export const Counter: React.FC = () => (
  <div>
    <p>Count: {count.value}</p>
    <button onClick={() => count.value++}>Increment</button>
  </div>
);

Here count.value is reactive: only the <p> updates when count changes, and you don’t need a separate setter or useState. As the Signals docs say, this offers “fine-grained reactivity”: only components that depend on a signal update, and you avoid tedious useMemo/useCallback calls. Signal values work well with TypeScript too (their .value can be typed).

  • Code Review Tip: Because signals exist outside of React’s hook system, ensure they’re initialized correctly (e.g. in module scope or via useRef, not re-created on every render). Review that you aren’t mixing signals and hooks carelessly – they use different mental models.

  • Common Pitfall: Forgetting to reset signals can persist state longer than expected. Also signals aren’t yet standard in React (as of 2025 you’d use a library), so ensure team agreement before using them widely. If you do use them, explain the pattern clearly in code comments or docs.


Wrapping Up: Best Practices and Tips

Throughout development and code reviews, watch for these general points:

  • Type Safety: Always type your props, context values, and custom hooks in TypeScript. This catches mismatches early. For example, define type User = { id: number; name: string } once and use it everywhere.

  • Separation of Concerns: Keep business logic out of your render functions. Whether using container-presenter or custom hooks, ensure a clear split: “logic vs view”.

  • Avoid Prop Drilling: Use context/providers judiciously. When more than 2-3 levels of props are needed, consider a Context API/provider to simplify code.

  • Leverage React 2025 Features: Use Suspense, RSC and possibly Signals for performance. But do so incrementally and clearly mark server vs client code.

  • Testability: Patterns like presentational components and hooks make unit testing easier. Write tests for your container logic and UI separately.


Staying current with React’s evolution is key. Practice these patterns, read recent articles, and don’t hesitate to iterate. As you grow, you’ll learn when to apply each pattern effectively. Happy coding!

Join Sheetal on Peerlist!

Join amazing folks like Sheetal and thousands of other builders on Peerlist.

peerlist.io/

It’s available... this username is available! 😃

Claim your username before it's too late!

This username is already taken, you’re a little late.😐

0

2

1