Your components know too much

The discipline isn't fewer components or fewer props. It's components that know less.

By Katia Wheeler ·

Your components know too much

I've spent a good chunk of my career building and maintaining design systems, and there's a specific failure mode I keep running into. It doesn't show up as an incident. There's no postmortem. One day you just notice that your "reusable" components are weirdly hard to reuse, and every single one of them seems to know way too much about your product.

I want to walk through how I think about that, because the usual diagnosis ("we have too many components, let's simplify") sends teams in completely the wrong direction. The count was never the problem. Let me start somewhere harmless.

The button you've definitely shipped

Here's a Button. You've written this Button. I've written this Button more times than I'd like to admit.

interface ButtonProps {
  isPrimary?: boolean;
  isSecondary?: boolean;
  isDanger?: boolean;
  hasIcon?: boolean;
  isLoading?: boolean;
  showSpinner?: boolean;
  isCompact?: boolean;
}

It started clean. Then someone needed a destructive action, so we added isDanger. Then a loading state, so isLoading, and later showSpinner because loading didn't always mean a spinner. Then an icon, so hasIcon. Every one of those was a reasonable five minute change. Nobody did anything wrong. And yet here we are with one component cosplaying as an entire family, plus a combo like isPrimary isDanger that should be impossible and absolutely isn't.

The instinct is to call this too many props, or too many components, and reach for "simplify." I think that framing is a trap. The real issue is that this component knows too much.

Booleans aren't the enemy, the wrong booleans are

Before we touch the Button, we need a rule, because someone always asks "so are all booleans bad?" and the answer is obviously no. disabled is a boolean and it's perfect.

Here's how I sort them. There are three kinds of boolean, and only one of them is actually fine.

  • Orthogonal state. Leave it alone. Things like disabled and loading. The tell is that more than one can be true at once, and each one toggles a real, independent thing. These are doing their job.
  • Mutually exclusive modes in disguise. isPrimary, isSecondary, isDanger. You'd never want two true at the same time, and if you've ever written a guard to stop that, that's the smell. These want to be a single variant.
  • Structure wearing a boolean costume. hasIcon, showActions, hasBadge. That isn't state at all. That's the caller describing layout, and layout is the caller's business. This wants to be composition, a slot or children, not a prop.

So the smell was never "boolean." It's a mode or a piece of structure pretending to be state. Collapse the first group into a variant union, push the structure into slots, and the Button mostly fixes itself.

variant?: "primary" | "secondary" | "danger";

But honestly, nobody's forwarding a blog post to their team over a Button. Buttons are the warm up. Here's the one that actually stings.

The card that knows about your database

Picture a UserCard. Reasonable team, reasonable component, lives in the design system.

// design-system/UserCard.tsx
import type { UserCardFragment } from "@/generated/graphql";
 
interface UserCardProps {
  user: UserCardFragment;
  showEmail?: boolean;
  showRole?: boolean;
  hasAvatar?: boolean;
  isCompact?: boolean;
  hasBadge?: boolean;
  showActions?: boolean;
  isClickable?: boolean;
}

Look at that first import. A component in your design system is pulling in a GraphQL type. Your shared, supposedly generic, supposedly reusable card now knows the exact shape of one product query.

I want to be fair here, because this is not a strawman. Nobody set out to build this. You needed a card for users, you had a UserCardFragment sitting right there, and wiring it straight in felt like the efficient move. It always does. This is genuinely where teams end up, which is exactly why it's worth talking about.

But this card is now stuck. It can't be reused anywhere that doesn't have that fragment. It can't be tested without mocking your data layer. It can't even go in Storybook without a fake GraphQL response. And it's hauling around all that boolean soup from the Button section on top of it. Every problem we've talked about, stacked into one screenful.

You kept the vocabulary and broke the boundary

Here's the part I find genuinely funny, in a slightly painful way.

A lot of teams that build components like this will also tell you, proudly, that they follow Atomic Design. Atoms, molecules, organisms, templates, pages. They've got the folder structure. They use the words.

But Atomic Design has a rule that's incredibly easy to skip, and it's honestly the most important idea in the whole methodology. Atoms, molecules, and organisms are presentational. They render what they're handed. Real data doesn't show up until the template and page layer. That boundary is the point of slicing things up this way.

So a UserCard that imports a GraphQL fragment didn't just get a little messy. It broke the one rule that made Atomic Design worth adopting in the first place. The team kept the vocabulary and threw out the boundary that gave the vocabulary any meaning.

And this is the thing that makes loving Atomic Design and hating data-coupled components totally consistent, even though it sounds like a contradiction. The methodology was never the problem. Breaking its boundary was.

The after

So we split the responsibilities. The card goes back to being purely presentational, and I build it as a compound component so the pieces are there when you need them, but the common shape stays consistent.

<UserCard>
  <UserCard.Avatar src={avatarUrl} name={name} />
  <UserCard.Name>{name}</UserCard.Name>
  <UserCard.Email>{email}</UserCard.Email>
  <UserCard.Badge variant="admin">Admin</UserCard.Badge>
</UserCard>

No GraphQL. No showEmail. It renders what it's given, full stop. Then the data and all the conditional logic move up into a feature component that's actually allowed to know about your product.

// features/team/TeamMemberCard.tsx
export function TeamMemberCard({ user }: { user: UserCardFragment }) {
  return (
    <UserCard>
      <UserCard.Avatar src={user.avatarUrl} name={user.name} />
      <UserCard.Name>{user.name}</UserCard.Name>
      {user.email && <UserCard.Email>{user.email}</UserCard.Email>}
      {user.isAdmin && <UserCard.Badge variant="admin">Admin</UserCard.Badge>}
    </UserCard>
  );
}

The fragment lives here now, in feature code, where it belongs. And notice I never went looking for a separate example to demonstrate "decomposition." It just happened. Breaking the monolith into small presentational pieces was the fix, not a different lesson.

"You didn't fix it, you just moved it"

Okay. If you're paying attention you're already annoyed, and you should be, because there's an obvious objection here and I'd rather say it out loud than let you catch me.

showEmail didn't disappear. It turned into {user.email && ...} one layer up. The boolean is still there. And while we're at it, one tidy line of JSX just turned into eight. That looks an awful lot like moving the mess around and slapping the word "architecture" on it.

Here's why it isn't.

  • The boolean didn't get eliminated, its ownership moved. The library component no longer carries the idea that "email is sometimes hidden." That decision belongs to whoever actually knows the data, and now it lives there. Moving a decision to the layer that owns it is not a side effect of the fix. It is the entire point. That's the whole thesis in one tiny example.
  • The verbosity is paid once. Those eight lines live in exactly one place. Every screen that renders a team member is still a one liner, <TeamMemberCard user={user} />. You pay the cost once and buy it back everywhere.
  • This is where the consistency fear dies. People worry that handing everyone a box of parts means every team assembles the card differently and you've reinvented the chaos a design system was supposed to kill. Fair worry. But that's not this. The compound component is the escape hatch for the rare case. The feature component is the default everyone reaches for. Two layers, two jobs.

How to do this without blowing up your week

This is where a lot of posts lose me, because the implied advice is "go rewrite your design system," and absolutely not. Nobody is getting that quarter approved, and you shouldn't ask.

So I'm not telling you to rewrite anything. I'm telling you to stop adding to the pile, and to fix coupling before cosmetics.

  • Strangle, don't rewrite. Ship the new compound API right alongside the old props. Mark the old booleans deprecated with a note pointing at the replacement, add a lint rule so new usages get flagged, and let old call sites migrate whenever someone's already in that file anyway. Boy scout rule, not a migration sprint.
  • Go after coupling first. The GraphQL fragment is the part that actually blocks reuse and testing, and you can lift it up to the feature layer without even touching the visual API. The boolean cleanup is a second, calmer pass. Coupling is load bearing. Booleans are just ugly. Don't confuse the two.
  • Codemod the boring stuff. Straight renames like isPrimary to variant="primary" are a script. Save your hands for the structural ones where a boolean becomes a slot. Track all of it as debt and never block a feature on it.

Closing thoughts

The lesson was never fewer components, and it was never fewer props. Plenty of healthy systems have loads of both.

The discipline is components that know less. Not where the data came from, not every case they might run into, just what they put on the screen. If you want a single thing to take back to your team, it's this:

Shape a component's API around what it displays, never around where its data comes from or every case it might face.

Do that, push everything else up to the layer that owns it, and most of the bloat you've been fighting quietly stops being a thing you have to fight.

Your components don't need more options. They need fewer secrets.