Avoiding premature abstraction with Unstyled React Components

Sam Selikoff

Sam Selikoff

Introduction

Unstyled components are a great way to share behavior in your React apps without having to create any styling abstractions prematurely.

Consider this newsletter form:

If you click Sign up, you'll see a loading spinner show up on the button while the form is submitting.

Here's the button's code:

<button className="relative rounded bg-amber-400 px-5 py-2 font-medium text-black">
  {isSubmitting && (
    <span className="absolute inset-0 flex justify-center items-center">
      <Spinner />
    </span>
  )}
  
  <span className={isSubmitting ? 'invisible' : ''}>Sign up</span>
</button>

The spinner renders in an absolutely positioned container to avoid any layout shift, regardless of what text is in the button.

Pretty nifty!

Here's another screen from the same app. Its for sending invoices, but the button on this page doesn't use the absolute positioning trick for its loading UI.

Click Send now to see what happens:

The spinner changes the button's size, causing some undesirable layout shift and making this screen feel sloppy and unpolished.


It sure would be nice if we could reuse that loading spinner code from our newsletter form in our invoice page!

Let's extract a component to do just that.

Extracting a styled button

Your first instinct here might be to extract a "design system component" for our two buttons. After all, we have a yellow button and a blue button, and we want them to both have the same loading UI.

So, we might end up with an API like this:

<Button color="yellow">
<Button color="blue">

and then both would be able to render the loading spinner using something like a loading prop:

// Newsletter form
<Button color='yellow' loading={isSubmitting}>
  Sign up
</Button>

// Invoice page
<Button color='blue' loading={isSending}>
  Send now
</Button>

Let's try it out:

<Button color="yellow" loading={isSigningUp} onClick={handleSignUp}>
  Sign up
</Button>
  
<Button color="blue" loading={isSending} onClick={handleSendNow}>
  Send now
</Button>

Seems to work great!

But we've actually just started down a bad path...

What's wrong with this approach?

Our new <Button> component is not only sharing the code for the loading UI, but also all the styling code for each of our buttons.

We've made a premature abstraction. If we want to add a green button to a new page, or change the size of the blue button without changing the size of the yellow one, we're going to have to open up our <Button> component and modify it. Every change in its usage will require a subsequent change to its internals.

That's a tell-tale sign of a bad abstraction. And we ended up with it because we inadvertantly extracted our button's styles in our attempt to extract its loading UI.

So – what should we have done instead?

Extracting an unstyled button

Let's take another look at the code for our first button:

<button className="relative rounded bg-amber-400 px-5 py-2 font-medium text-black">
  {isSubmitting && (
    <span className="absolute inset-0 flex justify-center items-center">
      <Spinner />
    </span>
  )}
  
  <span className={isSubmitting ? 'invisible' : ''}>Sign up</span>
</button>

The loading UI is a few things:

  • the isSubmitting span and Spinner
  • the span that hides the button text during submission
  • the fact that the button needs relative for the absolutely positioned spinner to work

This is really what we're trying to share, and it's a perfect use case for extracting an Unstyled Component.

Let's see how it works.

We're going to call this component LoadingButton, and we'll start out by passing it onClick and children:

function LoadingButton({
  onClick,
  children,
}: {
  onClick: () => void;
  children: ReactNode;
}) {
  return <button onClick={onClick}>{children}</button>;
}

Next, let's add a loading prop, the relative class, and the markup for both the Spinner and the span that wraps the children:

function LoadingButton({
  onClick,
  loading,
  children,
}: {
  onClick: () => void;
  loading: boolean;
  children: ReactNode;
}) {
  return (
    <button onClick={onClick} className="relative">
      {loading && (
        <span className="absolute inset-0 flex items-center justify-center">
          <Spinner />
        </span>
      )}
      
      <span className={loading ? "invisible" : ""}>{children}</span>
    </button>
  );
}

Let's see how we're doing so far:

<LoadingButton loading={isSigningUp} onClick={handleSignUp}>
  Sign up
</LoadingButton>

<LoadingButton loading={isSending} onClick={handleSendNow}>
  Send now
</LoadingButton>

Well, they don't look much like buttons anymore!

This is intentional. They're unstyled, so the caller is responsible for all the styling code.

Let's add a className prop so consumers can style our LoadingButton:

function LoadingButton({
  onClick,
  loading,
  className,
  children,
}: {
  onClick: () => void;
  loading: boolean;
  className: string;
  children: ReactNode;
}) {
  return (
    <button className={`${className} relative`} onClick={onClick}>
      {loading && (
        <div className="absolute inset-0 flex items-center justify-center">
          <Spinner />
        </div>
      )}

      <span className={loading ? "invisible" : ""}>{children}</span>
    </button>
  );
}

Note how we're still appending relative to the class list to preserve our Spinner's absolute positioning.

Time for the moment of truth...

<LoadingButton
  onClick={handleSignUp}
  loading={isSigningUp}
  className="rounded bg-amber-400 px-5 py-2 font-medium text-black enabled:hover:bg-amber-300 disabled:bg-amber-500"
>
  Sign up
</LoadingButton>

<LoadingButton
  onClick={handleSendNow}
  loading={isSending}
  className="rounded bg-sky-400 px-5 py-2 text-sm font-semibold leading-6 text-white enabled:hover:bg-sky-500 disabled:bg-sky-500"
>
  Send now
</LoadingButton>

Boom!

We've extracted an unstyled LoadingButton that gives the caller complete control over the UI.

If we wanted to add a new green button to our site, we can do it without making any changes to our component:

<LoadingButton
  onClick={handleSignUp}
  loading={isSigningUp}
  className="rounded bg-amber-400 px-5 py-2 font-medium text-black hover:bg-amber-300"
>
  Sign up
</LoadingButton>

<LoadingButton
  onClick={handleSendNow}
  loading={isSending}
  className="rounded bg-sky-500 px-5 py-2 text-sm font-semibold text-white hover:bg-sky-500"
>
  Send now
</LoadingButton>

<LoadingButton
  onClick={handleCheckout}
  loading={isCheckingOut}
  className="rounded-full border-2 border-emerald-500 bg-white px-5 py-2 text-sm font-bold text-emerald-600 hover:bg-emerald-50"
>
  Complete purchase
</LoadingButton>

Our new button looks nothing like our existing ones, but our LoadingButton component handled it without needing any modification.

That's a sign of a great abstraction!

Refactoring and polish

Our new LoadingButton is working great, but the astute reader may have spotted some areas for improvement:

  • The button should probably be disabled when its in a loading state
  • Our callers can't pass in any other button attributes, like type="submit"

To fix the first issue, let's make the button disabled when loading is true, as a convenience:

function LoadingButton({
  loading,
  className,
  onClick,
  children,
}: {
  loading: boolean;
  className: string;
  onClick: () => void;
  children: ReactNode;
}) {
  return (
    <button className={`${className} relative`} onClick={onClick} disabled={loading}>
      {loading && (
        <div className="absolute inset-0 flex items-center justify-center">
          <Spinner />
        </div>
      )}

      <span className={loading ? "invisible" : ""}>{children}</span>
    </button>
  );
}

Next, let's make sure to pass along any other valid button properties to our component's <button> element.

If you've never seen the ComponentProps type helper before, your mind is about to be blown:

function LoadingButton({
  loading,
  children,
  className,
  ...rest
}: {
  loading: boolean;
  children: ReactNode;
  className: string;
} & ComponentProps<"button">) {
  return (
    <button {...rest} className={`${className} relative`} disabled={loading}>
      {loading && (
        <span className="absolute inset-0 flex items-center justify-center">
          <Spinner />
        </span>
      )}

      <span className={loading ? "invisible" : ""}>{children}</span>
    </button>
  );
}

We're using JavaScript's spread operator to forward all other props directly to the underlying <button> element. Since onClick is a normal button prop, we don't need to apply it ourselves anymore.

The best part? ComponentProps provides type hints for our users, so anyone using LoadingButton gets full autocomplete in their editor, just like if they were rendering a plain old <button> element.

Let's give it a spin.

We can now pass type="submit" to our buttons, and we can even use its new disabled pseudostate to change the background color during submission via the disabled:grayscale Tailwind utility:

<LoadingButton
  onClick={handleSignUp}
  loading={isSigningUp}
  type="submit"
  className="rounded bg-amber-400 px-5 py-2 font-medium text-black enabled:hover:bg-amber-300 disabled:grayscale"
>
  Sign up
</LoadingButton>

<LoadingButton
  onClick={handleSendNow}
  loading={isSending}
  type="submit"
  className="rounded bg-sky-500 px-5 py-2 text-sm font-semibold text-white enabled:hover:bg-sky-600 disabled:grayscale"
>
  Send now
</LoadingButton>

<LoadingButton
  onClick={handleCheckout}
  loading={isCheckingOut}
  type="submit"
  className="rounded-full border-2 border-emerald-500 bg-white px-5 py-2 text-sm font-bold text-emerald-600 enabled:hover:bg-emerald-50 disabled:grayscale"
>
  Complete purchase
</LoadingButton>

Our LoadingButton is a perfect example of using an unstyled component to share some behavior, without jumping the gun on trying to predict future use cases or forcing our users into any premature styling abstraction.

Going further: Advanced React Component Patterns

Unstyled components are just one tool in the React toolbelt for making your apps easier to work on. We saw how they let us extract the specific part of our newsletter button that we wanted to share – but nothing else.

React has tons of incredible patterns for making reusable components in any situation where you'd want to share some code:

  • Renderless components for reusable behavior, layouts, or stateful UI components
  • Render props for yielding out state and logic for consumers to style themselves
  • Data props for styling components with multiple states – even when rendering them from a React Server Component
  • Compound components for exposing multiple components that have shared internal state

Becoming an expert React developer means knowing how to wield each pattern when its appropriate – and perhaps more importantly, when not to use a pattern, so you can avoid writing bad abstractions that make your codebases a pain to work on.

There's so much to cover here that it's the topic of my next Build UI course. It's called Advanced React Component Patterns, and it's going to teach you how to use the most important patterns from the React ecosystem to solve problems just like the one from this post.

My goal is that after taking the course, you'll never get stuck wondering:

  • Whether you should make a new component or a new hook
  • How you can share logic that's scattered across a ton of calls to useEffect, or
  • Whether render props or compound components are the right tool for the job

If you liked this post, you're going to love what I'll be teaching in the course.

You can check it out here to learn more and sign up for updates:

og-image v2.png

Until next time, happy hacking!

Last updated:

Get our latest in your inbox.

Join our newsletter to hear about Sam and Ryan's newest blog posts, code recipes, and videos.