Props and Attributes
Pass data and configuration into components through properties or attributes.
In this article
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. They all do the same thing with different hardcoded text.
Testing gets hard too. You can’t inject test data because the component fetches its own, and simulating error states is nearly impossible when everything is hardcoded inside the component.
Solution
Props let you pass data in from the outside instead of hardcoding it inside. A Button component accepts a label prop, so passing label="Submit" or label="Cancel" gives you one component to maintain instead of twenty.
Props can be anything: strings, numbers, objects, arrays, functions. You can pass user data, click handlers, and style variants, and the component renders accordingly.
Sensible defaults mean consumers don’t have to specify everything. A variant might default to “primary,” and disabled to false. A missing required prop should produce an error, and TypeScript makes that easy by flagging it at compile time.
Web components have two separate mechanisms: HTML attributes (always strings) and JavaScript properties (any type). Attributes work in declarative HTML; properties are for the JavaScript API.
Example
This Button component handles different labels, variants, and sizes, 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>
let { label, disabled = false, variant = 'primary', size = 'medium', onclick } = $props();
</script>
<button class={`btn btn-${variant} btn-${size}`} {disabled} {onclick}>
{label}
</button> class Button extends HTMLElement {
static get observedAttributes() { return ['label', 'disabled', 'variant', 'size']; }
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._onClick = null;
// Listen on the shadow root so the handler survives re-renders
this.shadowRoot.addEventListener('click', (e) => {
if (!e.target.closest('button')) return;
if (this._onClick) this._onClick();
this.dispatchEvent(new CustomEvent('button-click'));
});
}
connectedCallback() {
this.render();
}
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';
const size = this.getAttribute('size') || 'medium';
this.shadowRoot.innerHTML = `
<button class="btn btn-${variant} btn-${size}" ${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 covers many uses. The same Button works for “Submit,” “Cancel,” and “Delete” with a prop change.
- Testing gets easier. You pass mock data as props and check the output, without internal state getting in the way.
- Components become reusable across projects. A well-designed Button component works anywhere.
- Props suit Storybook well, since you can document every variant by passing different prop combinations.
- TypeScript catches mistakes at compile time. If you pass the wrong type to a prop, your editor flags it before the code runs.
- Logic stays centralized. Fix a bug once in the component, and all consumers benefit.
Tradeoffs
- Prop explosion is a real problem. With enough configuration options you end up with components that take twenty props, and the API gets hard to use.
- Conditional logic piles up, because most new props add another
ifstatement inside the component. - 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 read than omitting the prop. - 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, because each new one multiplies the number of combinations.
Summary
Props turn a single component into a building block that serves multiple use cases through configuration rather than duplication. Passing data, callbacks, and styling variants as props gives you components that make no assumptions about where their data comes from. Sensible defaults for optional props and TypeScript types keep the component API clear and predictable.
See also
- ARIA Roles and Attributes — Accessibility
- Semantic HTML — Fundamentals
- HTML Attributes - MDN
- Passing Props - React
- Props - Vue
- Props - Svelte