DEV Community

Niels Søholm
Niels Søholm

Posted on

SOLID in React #2 — The Open/Closed Principle

You build a component. It’s simple and clean—until someone needs “just one more variant.”
You add a flag, then another, maybe a conditional for a “special case.”
Suddenly, that tidy file has more ifs than a choose-your-own-adventure novel.

That’s exactly the situation the Open/Closed Principle (OCP) aims to prevent.

Open for extension, closed for modification.

In React, that means:
Don’t rewrite or add conditionals every time requirements grow.
Instead, design components that can be extended—and React’s composition model gives you that for free.

The smell: configuration-driven components that keep growing

Here’s a familiar anti-pattern:

// Modal.tsx (BEFORE)
import React from "react";

type ModalProps = {
  title: string;
  content: string;
  showFooter?: boolean;
  footerText?: string;
  onConfirm?: () => void;
  showCloseButton?: boolean;
};

export function Modal({
  title,
  content,
  showFooter = true,
  footerText = "OK",
  onConfirm,
  showCloseButton = true,
}: ModalProps) {
  return (
    <div className="modal">
      <header>
        <h3>{title}</h3>
        {showCloseButton && <button>x</button>}
      </header>
      <main>{content}</main>
      {showFooter && (
        <footer>
          <button onClick={onConfirm}>{footerText}</button>
        </footer>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This works fine—until you need a new variation:

  • A modal with a form
  • A modal with multiple action buttons
  • A modal that shows progress

Each new use case means adding props, conditionals, and more if statements.
Every change risks breaking something that already works.

The OCP mindset: composition over configuration

Instead of adding more props, make your component composable.
Let consumers decide what goes inside, while keeping the outer shell stable.

// Modal.tsx (AFTER)
import React from "react";

type ModalProps = {
  children: React.ReactNode;
};

export function Modal({ children }: ModalProps) {
  return (
    <div className="modal">
      <div className="modal-content">{children}</div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now the modal just provides structure.
Everything inside is up to the consumer:

// Example usages
<Modal>
  <h3>Delete item?</h3>
  <p>This action cannot be undone.</p>
  <button>Confirm</button>
</Modal>

<Modal>
  <h3>Upload progress</h3>
  <ProgressBar value={75} />
  <div className="actions">
    <button>Cancel</button>
    <button disabled>Uploading…</button>
  </div>
</Modal>
Enter fullscreen mode Exit fullscreen mode

No new props, no conditionals, no internal edits.
The Modal component is closed for modification, yet open for extension via composition.

Why composition works

Composition flips the responsibility:

  • The component defines structure and styling (the container).
  • The consumer defines behavior and content (the variation).

The component doesn’t need to know what’s inside—it just needs to provide the right “slot” for it.
That’s the Open/Closed Principle expressed the React way.

Other idiomatic OCP patterns in React

React has a few other ways to create these extension points.

1. Render Props — when extension needs behavior

Render props let the parent control what gets rendered while the component controls how state or effects are managed.

// DataLoader.tsx
import React, { useEffect, useState } from "react";

type DataLoaderProps<T> = {
  url: string;
  render: (data: T | null, loading: boolean, error: Error | null) => React.ReactNode;
};

export function DataLoader<T>({ url, render }: DataLoaderProps<T>) {
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<Error | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(url)
      .then((r) => r.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [url]);

  return <>{render(data, loading, error)}</>;
}
Enter fullscreen mode Exit fullscreen mode

Consumers decide how to display data, errors, or loading states:

<DataLoader
  url="/api/users"
  render={(data, loading, error) => {
    if (loading) return <Spinner />;
    if (error) return <ErrorMessage />;
    return <UserList users={data} />;
  }}
/>
Enter fullscreen mode Exit fullscreen mode

The data-fetching logic stays untouched; new visuals just plug in.

2. Slots — named composition for structured layouts

Sometimes you want predictable structure (like header/body/footer) while still allowing flexibility.

// Card.tsx
type CardProps = {
  header?: React.ReactNode;
  children: React.ReactNode;
  footer?: React.ReactNode;
};

export function Card({ header, children, footer }: CardProps) {
  return (
    <div className="card">
      {header && <div className="card-header">{header}</div>}
      <div className="card-body">{children}</div>
      {footer && <div className="card-footer">{footer}</div>}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Consumers can mix and match freely:

<Card
  header={<h2>User Info</h2>}
  footer={<button>Save</button>}
>
  <ProfileForm />
</Card>
Enter fullscreen mode Exit fullscreen mode

The card stays unchanged even as layouts evolve.

3. Hooks — extending logic instead of markup

Hooks let you extend or compose behavior rather than structure.

// useModal.ts
import { useState } from "react";

export function useModal() {
  const [open, setOpen] = useState(false);
  const toggle = () => setOpen((o) => !o);
  return { open, toggle };
}
Enter fullscreen mode Exit fullscreen mode

Different components can reuse and extend this logic however they like:

const { open, toggle } = useModal();

<button onClick={toggle}>Toggle</button>
{open && (
  <Modal>
    <h3>Hello</h3>
    <button onClick={toggle}>Close</button>
  </Modal>
)}
Enter fullscreen mode Exit fullscreen mode

The behavior is extendable and composable, yet the hook itself remains stable.

Practical benefits

  • Stable core components — no rewrites for new cases
  • Reduced risk — fewer regressions from prop bloat
  • Composable API — developers build new behavior from existing parts
  • Clear ownership — each unit does one thing and exposes extension seams

When you’re violating OCP

  • You’re adding new props or if branches every sprint
  • Components in common/ change constantly for unrelated features
  • Consumers need to fork or copy code to get small variations

In those cases, you’re missing a clean extension point.

Wrap-up

React makes the Open/Closed Principle feel natural: design components that can be composed, not configured.
If you’re adding another boolean prop or switch case, ask yourself:

“Could this instead be a child, a render prop, or a hook?”

When you embrace composition, your components stop growing in complexity and start growing in capability.

Top comments (0)