Published on
Time to read
11 min read

UI Kit: 7 Rules to Succeed

Dachshund in glasses watching at the drawing of a wheel

Rule #1: Don't reinvent the wheel

I have seen this rule being violated too many times, causing a lot of problems later on. The code accumulates and becomes ugly very quickly.

A good foundation for any UI kit is to first use what the browser provides, namely native components. A great and simple example is the plain Button component.

If you have worked on any UI kit that you've inherited from a previous developer, I'm sure you've seen this construction before:

bad-button.tsx
//❌❌❌
const BadButton: React.FC<{
  onClick: () => void;
  title: string;
  variant: "primary" | "secondary";
}> = ({ title, onClick }) => {
  return (
    <button onClick={() => onClick()}>
      {title}
    </button>
  );
};

Let's list the problems with the above code:

  1. You only enable what you deliberately described as props
    • Once you need to add any other on* event (onKeyPress, onChange…), you will be forced to make changes in the UI kit repository
    • Developers tend to make this too custom, with props like onMySpecialClick that usually just wrap the native events
  2. Types are wrong
    • All event props return the event payload, with all the native data, but it is lost.
    • A consumer would only guess how far the functionality of this button can go
  3. Components with custom APIs are hard to use with third-party libraries, adding a burden to your shoulders
    • Most integrations with third-party libraries, such as react-hook-form, are often easy with native elements and hard with custom ones
    • Having the Button be a button (API-wise) makes it more versatile
  4. The Button body is restricted to string values only
    • You can't pass other values
    • You can't pass other UI elements

Let's take a look at a much cleaner version:

good-button.tsx
//βœ…βœ…βœ…
const GoodButton = React.forwardRef<
  HTMLButtonElement,
  React.ButtonHTMLAttributes<HTMLButtonElement> & {
    variant: "primary" | "secondary";
  }
>(({ variant, children, ...props }, ref) => {
  // any custom logic
  return (
    <button {...props} ref={ref}>
      {children}
    </button>
  );
});

Improvements:

  • Now the consumer can use this button anywhere it was possible to use a native button
  • All the Button features and custom variant can be used
  • Types are correct and match the underlining implementation
  • Ref works
  • Children are not restricted, but you can override it if needed
  • Pre-condition for accessibility

It is essential to utilize the features provided by the browser before customizing it. This does not apply to all components, but the basic ones should abide by this rule.

For more intricate components, consider using a headless UI kit, such as Tailwind's https://headlessui.com/.

Rule #2: Start Testing Early

Dachshund programming in sci-fi style

If you plan on adding tests "later," forget about it. This is probably the biggest lie in software engineering.

It should be clear that tests are essential and required for developing reliable software. This is especially true for packages that many consumers might use. Who wants to use a dropdown that doesn't open when you type something? Or any other issue?

If you follow rule #1, you will only need to test a custom behavior since there is no point in testing native features. Isolate your tests and only test what is intended for the component you are covering.

Good tests will lead to a good component API (aka props).

Jest logo
Vitest logo

Recommended Tools to Use:

Rule #3: Reduce internal state with IoC

Dachshund on the rock, looking at the midnight city

Components with internal state can be useful, but they can also become challenging to manage when you need more control over them.

Let's revisit the previous examples. Here's a stateful component:

statful-modal.tsx
//❌❌❌
const StatefulModal: React.FC<{
  title: string;
  description: string;
  onOpen: () => void;
  beforeClose: () => void;
  onAccept: () => void;
}> = ({ title, description, onAccept }) => {
  const [isOpen, setIsOpen] = useState(false);
  const [searchValue, setSearchValue] = useState("");

  return (
    <>
      <button onClick={() => setIsOpen(!isOpen)}>Trigger</button>
      {isOpen && (
        <div>
          <input
            type="text"
            value={searchValue}
            onChange={(e) => setSearchValue(e.currentTarget.value)}
          />
          <span>{title}</span>
          <span>{description}</span>
          <div>
            <button onClick={() => setIsOpen(false)}>Cancel</button>
            <button
              onClick={() => {
                onAccept();
                setIsOpen(false);
              }}
            >
              Accept
            </button>
          </div>
        </div>
      )}
    </>
  );
};

As a consumer of this component, you face several issues:

  • No control over the modal visibility. If you have a side effect that should close it, you'll need to conditionally render the component itself
  • No control over the search bar, which is not easily accessible
  • No control over the action items. What if you only want the Cancel button to be visible? Do you add a new prop hideCancel: boolean?
  • It's hard to test as the component grows
  • It's hard to expand the component's functionality, as you have to consider all edge cases

Notice how many times control was mentioned? As a consumer, you don't have it. As the UI Kit author, you didn't provide it.

So, how can we solve the control problem?

Inversion of Control (IoC)!

If you're not familiar with the concept, we recommend reading Kent C. Dodds' article on Inversion of Control.

In short, IoC is an approach to developing an API, such as Props in this case, in such a way that the control is on the consumer side. Meanwhile, the provider holds the main business logic and restricts the consumer to a set of rules, which are the component's capabilities and API types.

Take a look at the updated example:

stateless-modal.tsx
const StatelessModal: React.FC<{
  title: React.ReactNode;
  children?: React.ReactNode;
  actions?: React.ReactNode;
  isOpen?: boolean;
}> = ({ title, children, actions, isOpen }) => {
  if (!isOpen) {
    return null;
  }

  return (
    <div>
      <span>{title}</span>
      <span>{children}</span>
      <div>{actions}</div>
    </div>
  );
};

Now that the Modal is stateless, we gain several benefits:

  • Control for the title, content, action, and visibility is returned to the consumer.
  • Testing this component is much easier
  • There is no if-branching for the edge cases.
  • It's easy to expand this component, and it's less likely you'll have to since the consumer has so much more control now.

But what are the downsides? There is one, clear, but not that big. With more control on the consumer side, there will be more repetition of the state handling whenever the Modal is used.

But that's alright. The UI Kit should provide building blocks, and the consumer application should utilize them efficiently. Here's how:

modal-with-search.tsx
const ModalWithSearch = () => {
  return (
    <StatelessModal
      title={"Can be text or component"}
      actions={
        <>
          <button>Cancel</button>
          <button>Accept</button>
        </>
      }
      isOpen={true}
    >
      <span>
        Search: <input type="text" />
      </span>
      <span>Description section</span>
    </StatelessModal>
  );
};

With the building block provided by the UI Kit, the consumer can prepare more specific implementations of the components, such as AcceptInviteModal or WarningModal, and so on.

Rule #4: Don’t overcomplicate props

Dachshund confused, looking at the laptop

When you violate rules, it can impact development speed and quality. Even if you are following best practices, you may still end up in a situation where you need to deliver custom functionality that does not fit into those practices. For example:

complicated-dropdown.tsx
const Example = () => {
  return (
    <Dropdown
      values={[1, 2, 3]}
      defaultValues={[]}
      overrideValuesOnReset={[0, 0, 0]}
      onSearch={() => {}}
      onSearchClear={() => {}}
      forceOpen
      onCloseOrOpen={() => {}}
      onMount={() => {}}
    />
  );
};

Initially, there was a nice and clean Dropdown component, which was properly typed and covered with tests. However, someone added a new property called overrideValuesOnReset to meet a business goal. This change didn't seem too bad, but it was quickly followed by the addition of forceOpen and onSearchClear to override the internal state of the component. Unfortunately, there was no time for refactoring.

As you can see, bad code accumulates quickly. Your once nice and flexible component can become cluttered with just a few new props.

The solution is simple: if you have a pressing need, do what you need to do, deliver, and then come back to refactor it. Doing only half the job will dramatically increase the cost of development, and nobody likes to use a UI Kit with overrideValuesOnReset props. Do your part responsibly so that you don't have to struggle in the future.

Rule #5: Ensure accessibility

Gone are the days when simple single-page applications (SPAs) with no accessibility, 10k spinners, and funny animations were popular.

Accessibility is now a required and well-deserved standard, and, like with tests, it's better to start early.

The rules above will be a good precondition to meet the goal. Native components are already quite accessible, and it's your responsibility to ensure that:

  • It is not worse than native
  • You have covered all the custom cases
  • You have tested it
Dachshund watching at the accrssibility sign

Accessibility is complex, but you can meet the basics with the right approach.

Check the ARIA guide page for a set of examples.

You might also consider using a headless UI Kit like Radix or Headless UI, which already provide great accessibility features. Additionally, consider using the ESLint plugin or a testing tool like axe-core for React.

It does not matter how you achieve the goal, as long as you achieve it.

Rule #6: Define boundaries

Two dachshund dogs keeping the distance between

Defining boundaries, rules, and practices is essential. Ensure that everyone can follow them.

Examples:

  • Write documentation and request it from others.
  • Use ESLint with a solid set of rules that work for you.
  • Don't neglect necessary refactoring.
  • Monitor test coverage and agree on a threshold.
  • Agree on file structure, and consider creating a template folder for new components.
  • Use code quality measurement tools, either locally or remotely.

The goal is to establish what is acceptable and encouraged and what should be avoided. Make sure you and your team are on the same page and agree on what is best for everyone, including the business.

Rule #7: Granularity and Composition

If you have read everything above, it should be clear that using simple and small components is a good way to develop a UI Kit. With that level of granularity, your components should be easily composable.

For example, consider the modal discussed in Rule #3. It accepts dynamic content as children, title, and action buttons, so the consumer can easily compose the final component in a way that best suits their needs.

When developing a UI Kit, you can export not only simple components, but also pre-composed ones like the ConfirmationModal, which could be used quite often. This is a middle ground between exporting components that are too simple or too complex.

Dachshund picture as atom shape

There are multiple ways to structure your components, and it’s up to you to choose. Here are two examples to consider:

Bonus Tip: Make your UI Kit Available

Your UI Kit should include a demo page to increase engagement. Nobody can envision the look of your component code in their head. Additionally, larger kits can be difficult to navigate and find components even for the creator.

If you don't know how to prepare a demo, use Storybook! It is a great tool for avoiding confusion within your team regarding available functionality, and not only.

With the right setup, many features can be enabled, such as:

  • Accessibility testing
  • Demo with different states or combinations
  • Deployable demo page for your non-dev teammates
  • A documentation repository
  • Quick access to the API references
  • Integration with design tools
  • And many more...