UI Component Ten Commandments
Behold! The etchings on this tablet contain essential wisdom for building excellent components. No singular commandment is greater than his brother, and no singular commandment is lesser than his sister. The following commandments do not always implement one another for brevity sake. Do not foresake them! Each commandment is authored to be overly concise, so as to focus on the principle at hand!
The following commandments are not specifically tailored for web components. These commandments are for UI component technologies en masse.
Summary: Components should be built from parts that semantically represent their anatomical structure, and should also minimize inlining of markup which could be represented by a logical boundary.
Principles: Composable design.
const Card: FC<Props> = ({
content,
title,
...unhandled
}) => (
/**
* Our card has three primary sections: a heading, body content, and footer.
* We can see semantic component names corresponding to the anatomical structure
*/
<div {...unhandled}>
<Heading>{title}</Heading>
{content}
<Footer />
</div>
);
Below we can observe the anatomy of our Card smashed together. There is no boundary
between the Heading and Footer, because there is none--it is all just Card.
The structure lacks distinct parts, leading to potential confusion.
Although a Card is a bit of a
mundane example, it's a component many web developers are familiar with.
const Card: FC<Props> = ({
content,
title,
...unhandled
}) => (
<div {...unhandled}>
<h1 className="my-header p-2 font-bold uppercase">
{formatHeader(title)}
</h1>
<footer className="my-footer flex bottom-0 p-2 justify-between">
<div className="my-footer-links">
{footerLinks.map((link) => {
return (
<a
key={link.url}
href={link.url}
target="_blank"
rel="noopener noreferrer"
>
{link.text}
</a>
);
})}
</div>
<img src={logo} alt="logo" />
<a
className="hover:cursor-pointer"
href="https://my.site.com/edit"
target="_blank"
rel="noopener noreferrer"
>
<img
src={editIcon}
alt="edit"
/>
</a>
</footer>
</div>
);
Assembling smaller components into larger components is a well
established strategy to build robust and malleable components. The above example
demonstrates the principle by isolating Heading and Footer from
Card component. Building small components supports...
Heading can be freely used elsewhere),Heading can be developed in isolation),Heading can be studied in isolation),Heading can be tested in isolation, or swapped in Card during test if needed), andWhen you are sent an image, design file, or visual spec like this:

you should see the anatomy like this:

then build those components accordingly.
Summary: Components should have intuitive, self-describing interfaces that reflect their purpose and behavior.
Principles: Principle of least astonishment, Declarative UI
type Props = {
// `isOpen` declares the modal to be open or closed.
// clear semantics make the component intuitive and
// predictable.
isOpen: boolean;
};
const Modal: FC<Props> = ({
isOpen,
...unhandled
}) =>
isOpen ? (
<Overlay {...unhandled} />
) : null;
Examples of non-declarative interfaces are abundant. They could use non-descriptive names and be missing documentation, like the following,
const Widget = ({
item,
data,
}: {
item: string;
data: any;
}) => (
<>
<span>{item}</span>
<span>{data}</span>
</>
);
but more often, bad interfaces come about by quickly building on the fly with shallow consideration for the UI itself, and letting the present data define the interface. It can be tempting to accept data in whatever shape you currently have it, and design a component interface around that rather than design an interface that is tailored to the component's intrinsics. Learn to identify these scenarios and avoid hastily accepting data structures that do not align with the component's purpose. "Declarative interfaces" is analogous to data modeling, but with focus at the component boundary.
// ❌ Bad example. Using foreign data model, vs component local data model.
// Let's build a component that shows a product's prices, with a discount!
type DiscountPriceProps = {
items: Item[]; // we accept all of the products,
index: number; // and which product index it is
// 🤔. Does a price viewer need knowledge of all of the items? Does it need
// to know about the index? Certainly not!
};
const DiscountPrice: FC<
DiscountPriceProps
> = ({
items,
index,
className,
...unhandled
}) => {
// The below logic handles cases that are not pertinent to drawing discounted
// prices--it is only relevant to handling one callsite's obscure data model.
// Data transformations should almost always be handled earlier in the call stack.
if (
!items ||
index < 0 ||
index >= items.length
) {
return null;
}
const item = items[index];
return (
<div
className={clsx(
"price",
className,
)}
{...unhandled}
>
<snip-snip-snip />
</div>
);
};
A better design, relative to the prior, would be akin to the following:
type DiscountPriceProps = Pick<
currentPrice: { scalar: number; uom: CurrencyCode },
previousPrice: { scalar: number; uom: CurrencyCode },
>;
const DiscountPrice: FC<
DiscountPriceProps
> = (props) => <snip-snip-snip />;
The declarative interface is clear and concise, focusing on the component's intrinsic data model rather than the external data model. This makes it easier to understand the component's purpose and how it should be used.
Writing declarative interfaces takes careful intention by the developer. Field names should represent clear descriptors of the data, or signal clear behavioral intent. This makes it easy for other developers to understand the purpose of each field and how it affects the component's behavior.
Summary: Design interfaces that only represent valid input and valid states, mitigating errors resulting from incomplete design or unexpected input.
Principles: Type safety, correctness
A classic example of this principle is using narrow types--such as discriminated unions--versus open types.
type Status =
| "loading"
| "success"
| "error";
const StatusBanner = ({
status,
}: {
status: Status; // versus, e.g. `status: string`
}) => {
switch (status) {
case "loading":
return <div>Loading...</div>;
case "success":
return <div>Success!</div>;
case "error":
return <div>Error occurred!</div>;
default:
// status is type `never`!
throw new Error(
`unknown status: ${status}`,
);
}
};
// ❌ Error case is unhandled, but everything will compile just fine!
const StatusBanner = ({
status,
}: {
status: string;
}) => {
if (status === "loading") {
return <div>Loading...</div>;
}
return <div>Success!</div>;
};
Making impossible states unrepresentable is a powerful principle that helps to ensure that your components are always in a valid state. By using a typed language, you can define precision input types for your component, which helps prevent the possibility of invalid or unexpected states from being passed in. Most UI systems let you define typed inputs.
// ❌ Bad example. Too wide of types, incorrectly modeling the domain.
/// Scenario: Consider if a Widget requires a foo or a bar as input:
type BadProps = {
foo?: string;
bar?: number;
}
// unhandled case could occur!
const Foo = (props: BadProps) => props.foo || props.bar;
// ✅ Improved
type VariantFooProps = { kind: "foo", value: string };
type VariantBarProps = { kind: "bar": value: number };
type GoodProps = VariantFooProps | VariantBarProps; // Use union types to restrict inputs
const Foo = (props: GoodProps) => props.kind === "foo" ? props.foo : props.bar;
Summary: Components shall extend their root child component and pass unhandled props. This makes components feel like a natural extension of the target UI runtime.
Principles: Extensibility by default.
type Props = {
foo: string;
} & HTMLAttributes<HTMLDivElement>;
const Widget: FC<Props> = ({
foo,
...unhandled
}) => <div {...unhandled}>{foo}</div>;
const Form: FC<{ fields: FormField[] }> = () => (
<form>
{fields.map((field) => <FormField key={field.id} {...field} />)}
</form>
);
What would a consumer possibly want to do with a form control? Perhaps:
onSubmit handler?id or className?data-* attribute?The takeaway is that there are many reasons to want to extend the form, even
if the component needs only light extension. Testing alone often warrants this
addition to components.
Sadly, this commandment is challenging
to achieve in some of the interesting,
nominally typed UI languages out
theres, such as Elm or ReScript,
at least without using a preprocessor.
Components shall by default extend their root child component. Any unhandled input (props, attributes, etc.) should be passed down to the underlying component or DOM element. This permits great extensibility and ensures that the component can be extended by the consumer without needing to modify the component itself.
Does the caller need to pass an id? How about a click handler?
Following this commandment, your component already supports your
caller's needs.
Summary: Separate presentational and logic dense concerns in components.
Principles: Separation of concerns.
// Presentational concerns
const UserCard = ({
name,
bio,
...rest
}: User & DivAttrs) => (
<div {...rest}>
<h1>{name}</h1>
<p>{bio}</p>
</div>
);
// Logic concerns
const UserCardContainer = ({
userId,
...rest
}) => {
const [userData, setUserData] =
React.useState<User | null>(null);
const { data, loading, error } =
useQuery(getUser);
if (loading)
return (
<div {...rest}>Loading...</div>
);
if (error)
return (
<ErrorCard
{...rest}
error={error}
/>
);
return (
<UserCard {...data} {...rest} />
);
};
// ❌ Avoid: monolithic components handling both logic dense
// operations, like API client fetching and rendering.
const UserDashboard = () => {
const [userData, setUserData] =
React.useState(null);
React.useEffect(() => {
fetch("/api/user")
.then((res) => res.json())
.then((data) =>
setUserData(data),
);
}, []);
if (!userData)
return <div>Loading...</div>;
return (
<div>
<h1>{userData.name}</h1>
<p>{userData.bio}</p>
</div>
);
};
In our UserCardContainer, the bulk of the code is focused on getting data and handling interstitial states. Once a state is known, we pass the state into a presentational layer to take over the rest.
The benefits of this are numerous:
Dan wrote about this over a decade ago,
and even states that it can be harmful. For very basic state handling, co-locating is ok.
However, we generally advice that separation be the rule, versus the exception,
if the developer knows there will be non-trivial state and logic. In the above
example, a UserCard could certainly be present on various pages, and it may
not always have the same data-source. Further, to support testing and visual tools
like Storybook, the separation of concerns is crucial, otherwise developer tools
must onboard the complexity of supporting the logic layer, even if it is not needed
in these contexts.
Principle: Encapsulation, Cohesion
import preprocessClick from "./preprocessors/onclick";
const Widget = ({
className,
criticalResource,
onClick: userOnClick,
...unhandled
}: {
criticalResource: Resource;
// ✂️ snip
}) => (
<div
// scoped/local styles, or utility styles minimize conflicts.
className={clsx(
className,
"widget-container b-none w-full",
)}
// behaviors rely on local &/or passed resources. minimized coupling
// to global or context-like implicit state.
onClick={preprocessClick(
userOnClick,
criticalResource,
)}
{...unhandled}
/>
);
In UI programming, encapsulation is key to building robust components. This means that a component should manage its own behavior and styles, rather than relying on external factors or global state. By encapsulating behavior and styles, you can ensure that your component is self-contained and can be reused in different contexts without unexpected side effects. Building strictly with this principle commonly ends up producing components that are functional (as in functional programming) in nature. Some developers observe that such components, in an application context, often fall victim to "prop drilling". There are a few strategies to mitigate this, including, but not limited to, using intra-app messaging, consolidating cross-cutting resources into a singular reference passed down, IoC for binding contextual behavior higher up in the tree, and more.
This commandment reduces local complexity, while often incurring a minor addition of complexity up your component tree, so as to support transporting input/props down to your component. Leveraging other commandments help to minimize this cost.
// ❌ Bad
const Widget = ({
children,
}: {
children: React.ReactNode;
}) => {
// implicit coupling to non-guaranteed resource
const criticalResource = useContext(
CriticalResourceContext,
);
return (
<div
className="global-container"
onClick={() =>
userOnClick(criticalResource)
}
>
{children}
</div>
);
};
Summary: Components should be built from parts that semantically represent their anatomical structure, and should also minimize inlining of markup which could be represented by a logical boundary.
Principles: Predictability, Stability
Module state, global, and contextual references should all be avoided.
In software design generally, it is crucial to avoid relying on module state, global state, and contextual references. This practice enhances predictability and stability within your application. By minimizing dependencies on external states, you can create components that are more self-contained and easier to test. This approach also reduces the risk of unintended side effects, making your codebase more maintainable and robust.
Principle: Portability
/// Setup - Consider component `Bar` which renders a `Foo`. The user may want
// to modify or change how `Foo` is rendered, including updating or overriding
// default props.
// foo.tsx
export const Foo = ({
foo,
...rest
}) => <div {...rest}>{foo}</div>;
// bar.tsx
type Props = {
/**
* Inversion of Control (IoC) utilizes callbacks to render the
* Foo component. This is known as the render-props pattern in React.
*/
foo?: ({
Component: Foo,
props: FooProps,
}) => JSX.Element;
};
const Bar: FC<Props> = ({
foo,
...unhandled
}) => {
const fooProps: FooProps = {
className: "bg-blue flex",
foo,
};
return (
<div {...unhandled}>
{foo({
Component: Foo,
props: fooProps,
}) ?? <Foo {...fooProps} />}
</div>
);
};
// Usage. Observe using
// your-app.tsx
<Bar
foo={({
Component,
props: { className, ...unhandled },
}) => (
<Component
id="my-foo"
className={`${className} my-classname`}
{...unhandled}
foo="Squashed foo!"
/>
)}
/>;
Inversion of control (IoC), as administered via render props, is a powerful technique that allows a component to delegate the responsibility of rendering a part of its interface to its caller. This is particularly useful when you want to provide a high degree of flexibility and extensibility in your component's design. By allowing the caller to specify how a part of the component should be rendered, you enable them to customize the component's behavior and appearance without needing to modify the component itself.