Back to the blog

Keeping Component Props Simple

When to split a component, when to use slots, and how to avoid prop objects that grow forever.


Keeping Component Props Simple

Props are a public API. Every new prop is a promise you will support—or a smell that the component is doing too much.

Favor composition over mega-props

If a card needs title, subtitle, image, imageAlt, badge, badgeColor, footerText, and footerLink, the component is guessing every layout variant callers might need. That guesswork turns into boolean flags and optional strings that interact in surprising ways.

Slots move the flexible regions to the caller:

<BlogCard :title="post.title" :date="post.date">
  <template #footer>
    <NuxtLink :to="post._path">Read more</NuxtLink>
  </template>
</BlogCard>
<!-- BlogCard.vue -->
<script setup>
defineProps({
  title: { type: String, required: true },
  date: { type: String, required: true },
});
</script>

<template>
  <article class="blog-card">
    <h2>{{ title }}</h2>
    <time>{{ date }}</time>
    <slot />
    <footer v-if="$slots.footer">
      <slot name="footer" />
    </footer>
  </article>
</template>

The parent owns markup for the footer; the card owns structure and spacing. When a new footer layout appears—share buttons, a tag list, a “featured” link—you do not add three props. You change the slot content.

I use the same split for headers, media, and actions. Ask: is this a fixed piece of data the card must understand, or a region that should accept arbitrary UI? Titles and dates are data. Badges and footers are often regions.

Composition also means splitting components. A ProjectCard and a BlogCard that share 30% styling should share a CSS class or a tiny presentational shell—not one UniversalCard with a type prop and two divergent templates. Duplication of three lines of markup is cheaper than a shared component that needs a wiki to configure.

Group related config, not unrelated flags

A single variant enum ("compact" | "featured") beats five boolean props that interact in weird ways. Boolean explosions look like this:

<!-- Hard to reason about -->
<Card
  :compact="true"
  :featured="true"
  :show-image="false"
  :dense="true"
  :bordered="false"
/>

Which combinations are valid? Does compact override dense? Can featured exist without an image? Call sites become experiments.

Prefer a small set of named modes:

<script setup lang="ts">
defineProps<{
  title: string;
  variant?: 'default' | 'compact' | 'featured';
}>();
</script>

Document what each variant changes—spacing, typography, whether media is required. When options are truly independent (for example, disabled on a button vs loading), separate booleans are fine. The smell is independent-looking flags that actually encode one design decision.

When a prop object grows (:options="{ a, b, c, d }"), ask whether those fields belong together as one concept. A coherent pagination object can be clearer than four loose props. An options bag that means “everything we did not want to name” is a warning sign—split the component or promote the important fields.

Document the contract

TypeScript interfaces or JSDoc on props pay off the moment someone else (or future you) reuses the component. Required vs optional should be obvious at the call site.

Good contracts answer:

  • What is required to render something useful?
  • What has a sensible default?
  • What emits fire, and with what payload?
  • What slots exist, and when are they optional?
<script setup lang="ts">
const props = withDefaults(
  defineProps<{
    title: string;
    description?: string;
    to: string;
    variant?: 'default' | 'compact';
  }>(),
  { variant: 'default' },
);

defineEmits<{
  select: [id: string];
}>();
</script>

I avoid props that are only there for one parent’s edge case. If a single call site needs a one-off layout, either use a slot, fork a local variant, or pass content as children. Polluting the shared API for one screen locks everyone into that exception.

Defaults deserve care. A default that hides important content (showTitle: true with title sometimes empty) creates empty headings. Prefer required props for content that defines the component, and defaults for presentation knobs.

Know when to split vs when to stop

Split when:

  • Two call sites use disjoint subsets of props
  • You need v-if on half the template based on a mode flag
  • The story of the component cannot be explained in one sentence

Do not split when:

  • The only difference is a class name (use a variant or class prop)
  • You are creating three files to avoid eight lines of duplication
  • The “components” cannot be understood without opening all siblings

A useful gut check: if removing one prop forces a rewrite of the examples in your README (or Storybook), the API may be too clever. Simple props survive refactors because callers can still read them.

Review props the way you review a public package API. Before merging a new optional flag, ask who needs it today, who will inherit it tomorrow, and whether a slot or a sibling component removes the need entirely. I keep a short “props changelog” in the component comment when a shared piece is used in more than two places—what was added, why, and what not to pile on next. That small note stops the slow crawl toward a mega-config object nobody wants to touch.

Wrap-up

Simple props stay maintainable: compose smaller pieces, use slots for flexible regions, group related options into clear variants, and document required vs optional at the boundary. Resist the boolean-prop explosion and the mega-card that accepts everything. Your future refactors—and anyone reusing the component—will thank you.