DEV Community

Soham Mondal
Soham Mondal

Posted on Originally published at sohammondal.com

Composition Over Configuration Is a Rule I Keep Re-Deriving

Every settings page in a growing app ends up with a modal that opens for profile info, or billing, or notification preferences, whatever product asked for last sprint. The lazy way to build that is one modal component with a section prop:

export type SettingsSection = 'profile' | 'billing' | 'notifications';

export type SettingsPanelChrome = {
  title?: string;
  size?: 'compact' | 'wide';
  dismissible?: boolean;
};

export interface SettingsModalProps {
  section: SettingsSection | null;
  onClose: () => void;
  chrome: Record<SettingsSection, SettingsPanelChrome>;
  footer: React.ReactNode;
  children: (active: SettingsSection) => React.ReactNode;
}
Enter fullscreen mode Exit fullscreen mode

That's roughly the shape a settings modal takes the first time someone builds it, before a refactor forces the issue. It looks reasonable. It compiles. It even feels DRY — one modal, one footer, one set of chrome rules, reused across every section. The catch shows up the next time product asks for a fourth section: you touch the section union, you touch the chrome record, you touch the children discriminator inside the modal, and you touch whatever component was rendering based on active. Four call sites for one new section, all inside a component that was supposed to be generic.

I keep running into this shape and keep tearing it back out. Not as a one-off cleanup — as a rule I apparently have to re-derive every few weeks, because the config-object version is always the one that gets written first.

A single configurable box branching into three outcomes on the left, versus three separate self-contained boxes on the right

The anti-pattern, defined once

A shared component that switches on a string or union discriminator — mode, type, variant — with a config map keyed by that same discriminator sitting next to it. New variant means editing the component that was supposed to be closed for modification. It reads like reuse. It's actually coupling: every variant now depends on the shared dispatcher knowing about it by name.

Case one: the settings-modal god-component

A modal-and-nav pair built around that section discriminator and a chrome record supplying per-section titles and sizing had grown to a few hundred lines before I replaced both with one self-contained component per section — ProfileSettings, BillingSettings, NotificationSettings — each owning its own modal ref, its own content, its own footer:

export const ProfileSettings: React.FC = () => {
  const modalRef = React.useRef<ModalHandle>(null);
  const { profile, updateProfile, saveProfile } = useProfileForm();

  return (
    <>
      <SettingsNavItem
        label="Profile"
        icon={UserIcon}
        isActive={false}
        onPress={() => modalRef.current?.open()}
      />
      <Modal ref={modalRef}>
        <ProfileForm value={profile} onChange={updateProfile} />
        <SaveButton onPress={saveProfile} />
      </Modal>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

The nav itself became a plain composition root:

export const AccountSettings: React.FC = () => (
  <SettingsNav>
    <ProfileSettings />
    <BillingSettings />
    <NotificationSettings />
  </SettingsNav>
);
Enter fullscreen mode Exit fullscreen mode

A fourth section now means writing a fourth file and adding one line to that list. Nothing else moves.

The payoff isn't just fewer files touched per new section — it's that AccountSettings can now be recomposed per surface without anyone touching a settings component. A support-tools screen that only needs billing and notifications doesn't need a visibleSections prop threaded through the old modal; it just renders fewer children:

export const SupportToolsSettings: React.FC = () => (
  <SettingsNav>
    <BillingSettings />
    <NotificationSettings />
  </SettingsNav>
);
Enter fullscreen mode Exit fullscreen mode

That's the actual argument for composition over configuration, not just the tidier one: a config object can only do what its author anticipated (add a visibleSections array, thread it through every layer, remember to keep it in sync). A tree of components can be rearranged by anyone, from outside, without touching the pieces being rearranged.

The nav item that sits in that list had its own smaller version of the same disease — a hand-rolled props type duplicating fields the design system's Tab already exposed. I fixed that the same day, by picking from the library type instead of re-declaring it:

export type SettingsNavItemProps = Pick<
  TabProps,
  'icon' | 'badge' | 'onPress' | 'disabled'
> & {
  label: string;
  isActive: boolean;
};
Enter fullscreen mode Exit fullscreen mode

That's a narrower version of the same instinct: don't build your own config shape when a real one already exists next to you.

Composition root
A component whose only job is to arrange other components — no branching, no business logic, no shared state of its own. AccountSettings above is one: it decides which sections exist, not how any single section behaves.

One crowded settings dialog with overlapping tabs on the left, versus three separate clean settings cards for profile, billing, and notifications on the right

That's the whole shape of the fix: three small things a caller can point at directly, instead of one thing a caller has to configure correctly.

The part that made it stick

Fixing the god-component once doesn't stop the next PR from reaching for a section prop again — it's the cheapest thing to write under deadline, and it compiles cleanly the first time. So in the same change that shipped the breakup, I also wrote down the judgment call as a rule file the AI coding agent reads before it touches this part of the codebase again — roughly:

---
description: "Split shared containers that branch on a mode/type/variant prop into one self-contained component per variant"
globs: "**/*.tsx"
---

# Composition over configuration

If a component picks its content, footer, or layout based on a
discriminator prop, that's a sign the variants should be separate
components instead. Give each variant its own trigger, its own
content, its own actions, and let the parent just arrange them.
Enter fullscreen mode Exit fullscreen mode

Not a wiki page — something the agent actually reads before it writes the next settings component. That's the whole judgment call, captured once so it doesn't depend on me remembering it three PRs from now.

Case two: the union that grew back two weeks later

Two weeks after the settings-modal cleanup, I was back in the same feature area rewriting the toggle primitives underneath it, and the same shape had regrown somewhere else. The shared toggle option type carried two ways to produce a label:

export type ToggleOptionLabel = { text: string } | { render: () => React.ReactNode };

export const resolveLabel = (label: ToggleOptionLabel) =>
  'text' in label ? label.text : label.render();
Enter fullscreen mode Exit fullscreen mode

Every toggle using the shared option type — a dozen of them, across profile, billing, and notification preferences — had to import resolveLabel and call it before rendering. A branch inside a mapper, fanned out across a dozen call sites, to solve a problem that didn't need solving: every option in practice just needed a plain string. The render branch existed for a customization case that, once I actually checked, no call site used.

I deleted the label-union type and the resolver — a customization escape hatch nobody had reached for — and gave the toggle a single field:

export type ToggleOption<Value extends string> = {
  value: Value;
  label: string;
  Icon?: ToggleIconComponent;
};

export const ToggleOption: React.FC<ToggleOptionProps> = ({ label, ...props }) => (
  <ToggleButton {...props}>{label}</ToggleButton>
);
Enter fullscreen mode Exit fullscreen mode

Every call site got simpler in the same diff:

const NOTIFICATION_OPTIONS: ToggleOption<NotificationChannel>[] = [
  { value: NotificationChannel.EMAIL, label: 'Email', Icon: Mail },
  { value: NotificationChannel.PUSH, label: 'Push', Icon: Bell },
];
Enter fullscreen mode Exit fullscreen mode

I didn't think of this as a bugfix or a cleanup while I was doing it. Looking back at the diff afterward, it was plainly just replacing a config builder with direct composition — fewer files, fewer indirections, the same behavior.

The same change did the mirror-image version of the fix on the compound component wrapping every settings group. It used to take a contentSpacing?: 'compact' | 'regular' prop and switch internally:

const Content = ({ children, spacing = 'regular' }) => (
  <div className={spacing === 'compact' ? 'content-compact' : 'content-regular'}>
    {children}
  </div>
);
Enter fullscreen mode Exit fullscreen mode

That's the same discriminator problem in miniature — a config flag deciding which style branch runs inside one shared node. I split it into two real leaves instead of one leaf with a mode switch:

const Content = ({ children }) => <div className="content-regular">{children}</div>;
const CompactContent = ({ children }) => <div className="content-compact">{children}</div>;

export const SettingsGroup = List as SettingsGroupComponent;
SettingsGroup.Content = Content;
SettingsGroup.CompactContent = CompactContent;
Enter fullscreen mode Exit fullscreen mode

Callers now write <SettingsGroup.CompactContent> when they want compact spacing, instead of passing a flag into a component that decides for them. Composition over configuration cuts both ways: sometimes it means deleting a union, sometimes it means turning a config prop into an actual leaf a caller can choose.

The rule, stated plainly

Neither fix was hard once I saw it. What's consistent is that I didn't see either one the first time a config object got written — I saw it weeks later, once a second variant made the branching visibly expensive. A mode prop with one value is invisible; with two, it still looks cheaper than two new files. It only starts looking wrong around the third or fourth branch, by which point it's load-bearing for every call site depending on it.

If two variants share most of their behavior, that's not evidence they should be one component with a switch — it's evidence they'll fork eventually, and the switch just defers the fork to a worse moment. Two small self-contained things, composed by a thin parent, beats one configurable thing almost every time the second variant is real rather than hypothetical. That's why a rule file earns its keep here in a way a PR comment doesn't: a comment fixes the diff in front of you; a rule file gets read the next time an agent — or me, a few weeks from now — is about to write the fifth branch of a config map, before it exists to comment on.

I still expect to re-derive this on some other screen. That's fine — the rule file just needs to catch it faster next time, not make sure I never write a mode prop again.

Top comments (0)