SOLID in React #2 — The Open/Closed Principle



This content originally appeared on DEV Community and was authored by Niels Søholm

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>
  );
}

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>
  );
}

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>

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)}</>;
}

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} />;
  }}
/>

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>
  );
}

Consumers can mix and match freely:

<Card
  header={<h2>User Info</h2>}
  footer={<button>Save</button>}
>
  <ProfileForm />
</Card>

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 };
}

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>
)}

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.


This content originally appeared on DEV Community and was authored by Niels Søholm