Props and Attributes
Problem
You need Submit, Cancel, and Delete buttons that look identical except for the label, but without props, you’re stuck copying nearly identical component files, changing one word, and repeating everywhere.
I’ve seen codebases with SubmitButton.tsx, CancelButton.tsx, DeleteButton.tsx, SaveButton.tsx, UpdateButton.tsx, ConfirmButton.tsx… you get the idea. All doing the same thing with different hardcoded text.
Testing becomes a nightmare too. You can’t inject test data because the component fetches its own, and simulating error states is nearly impossible when everything is hardcoded internally.
Solution
Pass data in from the outside instead of hardcoding it inside. That’s what props are for. A Button component accepts a label prop; pass label="Submit" or label="Cancel" and you have one component to maintain instead of twenty.
Props can be anything: strings, numbers, objects, arrays, functions. Pass user data, click handlers, and style variants, and the component renders accordingly.
Add sensible defaults so consumers don’t specify everything. A variant might default to “primary,” and disabled to false. Required props should throw errors if missing; TypeScript makes this easy.
For web components, distinguish between HTML attributes (always strings) and JavaScript properties (any type). Attributes work in declarative HTML; properties are for the JavaScript API.
Example
Here’s a Button component that handles every use case (different labels, variants, sizes, icons) all through props.
Basic Props
function Button({ label, onClick, disabled = false, variant = 'primary', size = 'medium', icon = null }) {
return (
<button
className={`btn btn-${variant} btn-${size}`}
onClick={onClick}
disabled={disabled}
>
{icon && <span className="btn-icon">{icon}</span>}
{label}
</button>
);
}
<Button label="Submit" onClick={handleSubmit} />
<Button label="Cancel" onClick={handleCancel} variant="secondary" />
<Button label="Delete" onClick={handleDelete} variant="danger" disabled={loading} /> <script setup>
const props = defineProps({
label: { type: String, required: true },
disabled: { type: Boolean, default: false },
variant: { type: String, default: 'primary' },
size: { type: String, default: 'medium' }
});
const emit = defineEmits(['click']);
</script>
<template>
<button
:class="`btn btn-${variant} btn-${size}`"
:disabled="disabled"
@click="emit('click')"
>
{{ label }}
</button>
</template> <script>
export let label;
export let disabled = false;
export let variant = 'primary';
export let size = 'medium';
</script>
<button class={`btn btn-${variant} btn-${size}`} {disabled} on:click>
{label}
</button> class Button extends HTMLElement {
static get observedAttributes() { return ['label', 'disabled', 'variant']; }
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._onClick = null;
}
connectedCallback() {
this.render();
this.shadowRoot.querySelector('button').addEventListener('click', () => {
if (this._onClick) this._onClick();
this.dispatchEvent(new CustomEvent('button-click'));
});
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) this.render();
}
set onClick(handler) { this._onClick = handler; }
render() {
const label = this.getAttribute('label') || 'Button';
const disabled = this.hasAttribute('disabled');
const variant = this.getAttribute('variant') || 'primary';
this.shadowRoot.innerHTML = `
<button class="btn btn-${variant}" ${disabled ? 'disabled' : ''}>${label}</button>
`;
}
}
customElements.define('custom-button', Button); Props Spreading
function Card({ title, children, className, ...rest }) {
return (
<div className={`card ${className || ''}`} {...rest}>
<h2>{title}</h2>
<div className="card-content">{children}</div>
</div>
);
}
<Card title="User Profile" className="profile-card" data-testid="profile-card">
<UserDetails />
</Card>
TypeScript Props
interface ButtonProps {
label: string;
onClick: () => void;
disabled?: boolean;
variant?: 'primary' | 'secondary' | 'danger';
}
function Button({ label, onClick, disabled = false, variant = 'primary' }: ButtonProps) {
return (
<button className={`btn btn-${variant}`} onClick={onClick} disabled={disabled}>
{label}
</button>
);
}
Props with Children
function Card({ title, footer, children }) {
return (
<div className="card">
{title && <div className="card-header"><h3>{title}</h3></div>}
<div className="card-body">{children}</div>
{footer && <div className="card-footer">{footer}</div>}
</div>
);
}
<Card title="User Profile" footer={<Button label="Save" />}>
<UserForm />
</Card>
Benefits
- One component, infinite uses. The same Button works for “Submit,” “Cancel,” “Delete” with just a prop change.
- Testing becomes trivial. Pass mock data as props, verify the output. No internal state to fight with.
- Components become reusable across projects. A well-designed Button component works anywhere.
- Storybook loves props. Document every variant by passing different prop combinations.
- TypeScript catches mistakes at compile time. Pass the wrong type to a prop and your editor screams at you before runtime.
- Logic stays centralized. Fix a bug once in the component, and all consumers benefit.
Tradeoffs
- Prop explosion is real. Add enough configuration options and you end up with components that take twenty props. The API becomes unwieldy.
- Conditional logic piles up. Every prop adds another
ifstatement inside the component. Complexity grows. - Prop drilling gets tedious. Passing data through five layers of components to reach a deeply nested child is annoying and error-prone.
- Default values can hide bugs. If someone passes
undefinedand it silently falls back to a default, you might not notice the upstream problem. - Boolean props read awkwardly.
disabled={false}is harder to parse than just omitting the prop entirely. - Spreading props (
{...rest}) loses type safety. You don’t know what’s being passed through. - Web component attributes only support strings. Complex data needs JSON serialization or separate property setters.
- The more optional props you have, the more combinations you need to test. Exponential test scenarios are no fun.
Summary
Props transform a single component into a flexible building block that serves multiple use cases through configuration rather than duplication. Pass data, callbacks, and styling variants as props to create components that work anywhere without internal assumptions about where their data comes from. Add sensible defaults for optional props and TypeScript types for safety, keeping your component API clear and predictable.