Software Craft
Designing Clean and Maintainable User Interfaces
Updated 2026-07-06 · 7 min read
A user interface has two audiences: the people who use it and the people who maintain it. Most advice focuses on the first. This guide is mostly about the second — because a UI that users love but developers dread is a UI that slowly stops improving.
The good news is that the two goals mostly agree. Interfaces built from clear, consistent pieces tend to be both easier to use and easier to change. The examples below use React-style components because they are familiar, but the ideas apply to any component framework.
Draw component boundaries around responsibilities
A component should do one thing and have one reason to change. When a component fetches data, manages a form, formats currency, and renders a modal all at once, every one of those concerns can break the others, and none of them can be reused.
The clearest split is between components that decide what to show and components that decide how it looks:
// Presentational: given data, renders it. No idea where data came from.
function UserCard({ name, avatarUrl, onFollow }) {
return (
<div className="card">
<img src={avatarUrl} alt="" />
<h3>{name}</h3>
<button onClick={onFollow}>Follow</button>
</div>
);
}
// Container: owns the data and behavior, delegates the rendering.
function UserCardContainer({ userId }) {
const user = useUser(userId);
const follow = useFollow(userId);
return <UserCard {...user} onFollow={follow} />;
}The presentational UserCard is trivial to test, reuse, and preview in isolation, because it is a pure function of its props. Keep this instinct even when you do not split every component — a component that only takes props and renders is far easier to reason about than one reaching into global state.
A rough sizing heuristic: if a component file is getting long enough that you scroll to understand it, or its name needs an "and" to describe it, it is probably two components.
Be deliberate about state
Most UI complexity is really state complexity. The more places state can live and the more copies of the same truth exist, the more ways the UI can contradict itself.
Two rules carry most of the weight:
Keep a single source of truth. Never store the same fact in two places that can drift apart. If you keep both a list of items and a separate count, they will eventually disagree — derive the count from the list instead. Compute, do not duplicate.
Put state as low as it can go. Local state that lives inside the one component using it is easy to follow. State only needs to move up to a shared parent when two components genuinely must agree on it. Lifting everything into a global store "just in case" turns every small change into a global concern.
// Derived, not stored — count can never disagree with items.
function Cart({ items }) {
const total = items.reduce((sum, i) => sum + i.price, 0);
return <footer>{items.length} items · ${total.toFixed(2)}</footer>;
}Reach for a global store or context only when prop-passing genuinely hurts — deeply shared things like the current user, theme, or auth. Global state is a real cost: it couples distant parts of the app and makes changes ripple. Local until proven shared.
Build on a consistent system, not one-off styles
Consistency is what makes an interface feel trustworthy, and it is what keeps the CSS from becoming a swamp. The enemy is the magic number: a 13px here, a #3a3a3a there, a 7px margin somewhere else, each chosen by eye in the moment and impossible to change globally later.
The fix is design tokens — a small set of named, reusable values that everything draws from:
:root {
--space-1: 4px;
--space-2: 8px;
--space-3: 16px;
--space-4: 24px;
--color-text: #1a1a1a;
--color-accent: #2563eb;
--radius: 8px;
}
.card {
padding: var(--space-3);
border-radius: var(--radius);
color: var(--color-text);
}Now spacing comes from a scale, not a whim, and changing the accent color is one edit instead of a hundred. A spacing scale (often multiples of 4 or 8) also makes layouts feel harmonious without you having to think about it. This is the seed of a design system: reusable tokens plus a small library of components everyone uses instead of reinventing buttons.
Consistency also means behavior. A primary button should look and act the same everywhere. If every screen invents its own button, users have to relearn each one and you have to fix bugs in a dozen places.
Make it accessible from the start
Accessibility is not a feature you bolt on at the end — retrofitting it is painful, and building it in is mostly free. It also overlaps heavily with clean code: semantic, well-structured markup is both more accessible and more maintainable.
The highest-leverage habits:
- Use the right element. A button is a
<button>, not a<div>with an onClick. Native elements come with keyboard support, focus behavior, and screen-reader semantics you would otherwise reimplement badly. - Label every input. Every form field needs a real
<label>. Placeholder text is not a label — it vanishes when the user types. - Give images meaningful
alttext, and emptyalt=""for purely decorative ones so screen readers skip them. - Make it keyboard-navigable. Tab through your interface. If you cannot reach or activate something with the keyboard, neither can a lot of your users.
- Check color contrast. Aim for the WCAG AA ratio of at least 4.5:1 for normal text. Light-gray-on-white looks elegant and is unreadable for many people.
- Manage focus. When a modal opens, move focus into it; when it closes, return focus where it was.
Test with your keyboard and a screen reader occasionally. Ten minutes of tabbing through a page catches most of the serious problems.
Handle the states you would rather ignore
Designs are usually drawn in the happy path: data loaded, everything present. Real interfaces spend a lot of time in the other states, and unhandled ones are where UIs feel broken.
For any view that loads data, plan for all of it:
- Loading — show a spinner or skeleton, not a blank screen that looks frozen.
- Empty — the list has zero items. An empty state is a chance to guide ("No projects yet — create your first"), not a void.
- Error — something failed. Say so clearly and offer a way forward, like a retry.
- Partial / too much — very long names, tiny screens, a thousand rows. Design for the extremes, not just the tidy mock.
Building these in from the start keeps the logic clean. Bolting them on later usually means a tangle of conditionals wrapped around code that assumed success.
Common mistakes
- God components. One file that fetches, transforms, manages forms, and renders. Impossible to test or reuse.
- Duplicated state. The same fact stored twice, guaranteeing they eventually disagree. Derive instead.
- Everything global. State lifted into a store "just in case", coupling the whole app.
- Magic numbers everywhere. Hardcoded pixels and colors that cannot be changed globally and never quite match.
<div>soup. Divs with click handlers instead of buttons and links, throwing away built-in accessibility.- Accessibility as an afterthought. Far more expensive to retrofit than to build in.
- Only designing the happy path. No loading, empty, or error states, so the UI feels broken the moment reality intrudes.
- Premature abstraction. A configurable mega-component with fifteen props before you have two real use cases. Duplicate first, abstract on the third.
Checklist
- Components have one clear responsibility; long ones are split.
- Presentational and data-owning concerns are separated where it helps.
- There is a single source of truth; derived values are computed, not stored.
- State lives as locally as possible, global only when truly shared.
- Spacing, color, and radius come from named tokens, not magic numbers.
- Interactive elements use real
<button>and<a>tags. - Every input has a label and every image has appropriate alt text.
- The whole interface is reachable and operable by keyboard.
- Text meets at least 4.5:1 contrast.
- Loading, empty, and error states are designed, not just the happy path.
Keep learning
For accessibility, the WAI-ARIA Authoring Practices show how common components should behave with keyboard and screen readers — invaluable when you must build something custom. The MDN accessibility docs are a reliable everyday reference.
To go deeper on structure, read up on your framework's official guidance on component design and state management, and study a mature design system like Material or the U.S. Web Design System to see tokens and component libraries done well. Then pair this with How to Structure a Software Project — the same instincts about boundaries apply to your component tree — and Writing Better Technical Documentation for documenting the components you build so others can reuse them.
