Writing good react components banner with various graphics

Designing Good React Components

Part 4 of 10 in React Lunch & Learn

Part of the React Lunch & Learn series. If you’ve been following along, you’ve got the parts—custom hooks, the render cycle, state management. This post is about the whole: what makes a component actually good.

A good component is so much more than one that Just Works™.

When I was learning React, I contributed to a codebase that was all over the place: components with copied-and-pasted HTML, messy APIs, and inconsistencies everywhere. Each one trapped focus differently—or, more honestly, didn’t. One of them stole focus from screen readers and never gave it back. Every one of them “worked.” A lot of them were bad; bad from a DX standpoint, poorly written, and inconsistent. Lots of technical debt. But at the time, I didn’t know any better.

There were two podcast episodes that were pretty formative in cultivating my understanding of better practices. The episodes were ShopTalk Show Episode #387: Becoming a Front-End Architect With Katie Sylor-Miller and ShopTalk Show Episode #216: With Val Head and Sarah Drasner. In the episodes, Katie is a front-end architect at Etsy, and if you don’t know Val Head and Sarah Drasner—I’m sorry—but they’re brilliant. They’re two of the greatest developers in web animation.

So what makes a React component “good,” anyway?

Before we can do anything else, we need to be able to build good components. A good component is dumb on the outside and composed of smart parts on the inside: presentation driven by props, behavior moved into hooks, data flowing down, and events flowing back up via callbacks. We’ve leaned on that since the intro to React and file organization.

React itself enforces the spine of this. Components are meant to be pure functions of their inputs—the Keeping Components Pure docs are very direct about it: “React components you write must always return the same JSX given the same inputs,” and they must not mutate anything that existed before rendering. Props flow strictly parent-to-child and are read-only from the child’s view; the Passing Props docs say if a child needs different props, it has to “ask” its parent for them. That’s not a style choice. That helps to form the contract.

But “pure and prop-driven” is table stakes. It tells you the component won’t misbehave. It doesn’t tell you the component is good. For that, we need two more layers: it has to be a reliable, accessible, reusable building block—and it has to feel right when it moves.

Katie Sylor-Miller: build the hard things once, build them accessible

On ShopTalk #387 (“Becoming a Front-End Architect,” with hosts Chris Coyier and Dave Rupert, Nov 2019), Katie Sylor-Miller described what her architecture team at Etsy actually does:

“We provide canonical implementations of [design-system components like overlays, tabs—the ones that are really complicated to get right for accessibility] for product teams to use.”

And the reason behind it:

“Our job really was to support all of those product teams and to make sure that they have the tools and the processes in place that they need in order to build interfaces that are performant, that are reusable, that are accessible.”

The reason you centralize a component is not to save keystrokes. It’s that some components are genuinely hard to get right—overlays, tabs, menus, focus management—and getting them wrong is an accessibility failure that ships to every team that uses the broken version, which metastasizes across your codebase. Inexperienced developers will see an example of how to do something (whether good or bad) and use it in their own work. So you build the hard thing once, build it correctly, and let everyone else compose it.

Sylor-Miller framed her own role as making the connections across teams “to make sure that we’re solving the right problems,” and she invoked the idea of “influence without authority”—you can’t mandate a good Dialog component, you have to make the good Dialog the obvious one to reach for. This is a really crucial soft skill that I don’t want to understate. The idea of influence without authority is effectively getting buy-in from multiple teams by making your thing the obvious one to use. This might involve seeking feedback, improving, or compromising to make it valuable to a wide range of teams.

A “canonical implementation” is the official component library with a clean props API—and the cleanest version is composition over configuration. Don’t expose forty boolean flags. Expose a slot and let callers fill it. React hands you exactly this with the children prop: the Passing JSX as children docs describe a component as “having a ‘hole’ that can be ‘filled in’ by its parent components with arbitrary JSX.”

So a canonical Dialog isn’t a config soup. It’s a small, composable family:

// The accessibility-critical machinery lives here, once.
// Focus trap, Escape-to-close, aria roles, scroll lock—all in Dialog.
function Dialog({ open, onClose, children }) {
if (!open) return null;
return (
<Overlay onClick={onClose}>
<FocusTrap>
<div role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
{children}
</div>
</FocusTrap>
</Overlay>
);
}
// Product teams compose; they never re-roll the focus trap.
<Dialog open={isOpen} onClose={close}>
<Dialog.Title>Delete this movie?</Dialog.Title>
<Dialog.Body>This can't be undone.</Dialog.Body>
<Dialog.Footer>
<Button onClick={close}>Cancel</Button>
<Button variant="danger" onClick={remove}>Delete</Button>
</Dialog.Footer>
</Dialog>

Notice the API shape. Dialog is controlledopen and onClose come from the parent, which is React’s definition of a controlled component: “the important information in it is driven by props rather than its own local state” (Sharing State Between Components). The hard accessibility work is tucked away inside; the caller just composes content into the slots. That’s Sylor-Miller’s “performant, reusable, accessible” idea expressed as a props API.

One more React detail that matters for a good shared component: sensible defaults. You set them with default parameters—function Button({ variant = "primary" }). Just know the sharp edge: per the Passing Props docs, a default only kicks in when the prop is missing or explicitly undefined. Pass null or 0 and you get null or 0, not the default. A canonical component handles that gracefully instead of surprising the eleven teams downstream.

The React ecosystem now ships exactly the “canonical, accessible” primitives Sylor-Miller was describing—Radix UI, React Aria, Zag. Those are ecosystem tooling, not React-core, but they’re the modern answer to her point: don’t hand-roll a focus trap, compose a battle-tested one. Build the hard things once; or don’t build it if you find a trusted library.

Val Head: motion is part of the component, not the icing

Sylor-Miller’s advice gives you a building block that’s easy to use and easy to use again. But “accessible and reusable” just means the component is sitting there, not doing anything. The next step—the one most guides ignore—is figuring out how it actually moves.

On ShopTalk #216 (with Val Head and Sarah Drasner, hosts Chris Coyier and Dave Rupert, May 2016), Val Head made the case that animation is a design tool on the same shelf as type and color. Just as a typeface “can say something about your content or your brand,” she argued, animation can too—it’s not decoration you sprinkle on at the end.

She nailed the analogy for the bolt-it-on mistake. If you wait until the end to add animation, it’s just the icing on the cake. But really, you want animation mixed into the batter from the start. That’s the difference between a good component and a flashy one: in a good component, motion is part of the recipe, not something you slap on at the last minute.

And motion carries brand voice. Head’s example: a bounce ease on a serious bank’s site would read as “why are you guys playing around with the money?”—whereas a playful brand wants that bounce. Same easing curve, opposite meaning, depending on voice and tone. She pointed at the actual voice and tone document as the guide: if the copy for “we’re deleting your account” is somber, cut down on the motion there too.

The trick for making this repeatable (and this is where React comes in) is using tokens. Head described Shopify’s motion style guide as a short list of easings—a handful of Sass variables—so everyone is dipping into the same bucket. She also suggested starting with a motion audit, which is just taking inventory of what motion you already have before you try to standardize it.

In React, the tokens are just CSS custom properties (or a tiny tokens module), and the component consumes them instead of hardcoding magic numbers:

/* motion-tokens.css—the "same pool" everyone pulls from. */
:root {
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-entrance: cubic-bezier(0, 0, 0.2, 1); /* ease-out, more on that below */
--duration-fast: 150ms;
--duration-base: 240ms;
}
// The component owns its transition, sourced from brand tokens.
function MovieCard({ movie }) {
return (
<article className="movie-card">
<img src={movie.poster} alt={movie.title} />
<h3>{movie.title}</h3>
</article>
);
}
.movie-card {
transition: transform var(--duration-fast) var(--ease-standard);
}
.movie-card:hover {
transform: translateY(-4px);
}

You won’t find a sneaky 0.6s ease-in-out hiding in some forgotten stylesheet. When a card lifts on hover, it uses the same motion settings as everything else. The whole app moves together, like a choir singing in tune. That’s what Head means by putting the ‘entire cake’ into a token file.

Sarah Drasner: make motion functional, and document it so it survives

Head explained how to organize motion: turn it into tokens, keep it on-brand, and make it part of the foundation. On that same episode, Sarah Drasner focused on why we use motion at all. She talked about motion that actually does something useful, and motion that can survive when a big team gets their hands on the code.

Her most useful tip feels a little backwards at first: use ease-out for entrances. If you use ease-in, the animation starts slow and makes people wonder if they actually clicked. Ease-out, on the other hand, pops in with confidence and then disappears. That’s why my token file above separates --ease-entrance from --ease-standard.

She also gave animation a new job: making things feel faster, even if they aren’t. If you use transitions in the right places, waiting feels shorter and the app feels snappier. A loading animation that actually shows progress isn’t any quicker in real time, but it feels faster than just staring at a stuck spinner. That’s what functional animation means—motion that tells the user what’s really happening.

In React, “state transition” is the natural seam for this, because your component already knows its state—it’s right there in the render. You animate the transition between states to communicate what changed:

function MovieList({ movies, status }) {
if (status === "loading") return <Skeleton count={6} />;
return (
<ul className="movie-grid">
{movies.map((movie) => (
// enter/exit animation communicates "this item arrived/left"
<FadeIn key={movie.id}>
<MovieCard movie={movie} />
</FadeIn>
))}
</ul>
);
}

The skeleton-then-fade pattern is Drasner’s way of handling perceived performance. The skeleton says to the user, “hang on, something’s loading,” and the fade says, “okay, now you can see it.” The key prop is also important for React’s reconciliation, but that belongs to the render-cycle discussion from earlier.

Drasner made it clear: on big teams, documentation is what keeps things from falling apart. At Trulia, she had to make iOS, Android, and web all feel like the same product. Good documentation meant anyone could rebuild the animation in another language and still get the same result. On a big enough team, she said, design style guides are almost everything, because you just can’t talk to everyone directly.

If that sounds familiar, it’s because Sylor-Miller called it “influence without authority.” Two guests, two episodes, but the same point: you scale by making the good thing easy to reuse, not by forcing it on people. A component library is just a style guide you can drop into your project.

Her process advice was simple and practical: show, don’t just tell. Build a tiny prototype (she liked CodePen for this), walk away for a bit, then come back and see if the animation feels over the top.

Restraint and respecting prefers-reduced-motion

This is where Head and Drasner landed on the same answer. They both argued that the right amount of animation depends on the situation, and that useful animation should usually be subtle. The best motion is the kind you barely notice.

Today, React shows that restraint by respecting prefers-reduced-motion. That media query wasn’t around in 2016, but it’s now the standard for accessibility. It lines up with the kind of restraint Sarah Drasner has been talking about for years.

In React, it’s a small hook, and your animated components should respect it:

function useReducedMotion() {
const [reduced, setReduced] = useState(
// Guard `window` so this is safe under server-side rendering.
() =>
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
useEffect(() => {
const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
const onChange = () => setReduced(mql.matches);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, []);
return reduced;
}
function FadeIn({ children }) {
const reduced = useReducedMotion();
return (
<div className={reduced ? "" : "fade-in"}>
{children}
</div>
);
}

Note the shape: a smart hook (useReducedMotion) feeding a dumb component (FadeIn). Props down, behavior in the hook. Same pattern as the post on designing good custom React hooks. The restraint principle lands as architecture, not just a media query.

Putting it together

So, to avoid mistakes like I made when I first learned React, remember that a good React component is:

  • A canonical, accessible, reusable building block (Sylor-Miller, #387)—the hard accessibility machinery sealed inside, composed via children and slots, controlled through a clean props API, with sensible defaults. Build the hard things once.
  • Moving with intent, systematized as tokens, tied to brand voice (Head, #216)—motion baked into the component from the start, easing and duration pulled from a shared pool, never iced on at the end.
  • Animating its state transitions functionally and respecting restraint (Drasner, #216, plus modern prefers-reduced-motion best practice)—motion that communicates and improves perceived performance, documented so it survives a big team, subtle by default.

And all of it composed the way this series has insisted from day one: dumb components, smart hooks, props down and callbacks up, pure functions, no in-place mutation. Build the hard parts once, make them accessible, give them a voice—and let everyone compose.