Defining the API of a Component Library

Posted on March 16, 2019  -  👩🏽‍💻 15 min read

Component libraries are abundant in the world of React, there’s so much of them that there’s even a categorized list of them.

And still with all that wealth, you’re building & maintiaing your own component library. Maybe because your company has its own design language, or you believe you can do better, or perhaps you have a missing piece of the component library puzzle that makes the entire thing much better!

No matter the reason, if you’ve been maintaing your library for at least a little bit of time, you’ve found yourself rebuilding some components. Why is that? at what point did you need to rebuild a component? what prompted that?

Some of the problems you might be facing with your current implementation are:

  • There are a lot of bugs.
  • Hard to reason about.
  • Can’t add new features.

All of above could be summarized with one word - maintainability. You want need to maintain that code for a long time, and for obvious reasons, you want to make your life easier.

So, how do we do that? how do we make our components maintainable? properly define the API of a component.

Let’s define an API

How does defining an API help? Let’s look back at what is an API. Take a function for example:

function add(a, b) {
  return a + b;
}

If we’ll go back to CS101 we know that the signiture of a function consists of the parameters (and their types), the name of said function and the return type. Using that signiture is how the compiler differentiates between two functions, whenever one of the items in the signiture changes the compiler will refer to a different function.

Let’s use that, if the compiler thinks this signiture is good enough, why shouldn’t we? This signiture would be the API of our function. Let’s also look at our add function as a black box, let’s assume we know nothing about the code inside. What do we expect from add?

  • We expect it to be named add, not addition or sum.
  • We expect it to take 2 numeric parameters, not 1, not 3, not a number and an object.
  • We expect it to return a number, not a date, not boolean.
  • Last, but not least, we expect it to convey the intent of returning the addition of two numbers.

You might’ve noticed the last bullet isn’t part of the original function signiture, we’ll look at that later.

This definition allows us to use add without actually knowing that it works or how it works. We’re building upon the above assumptions of add, and use it accordingly.

It’s important because it gives the author of add the freedom to do this crazy thing:

function add(a, b) {
  return 0 + a + b;
}

This is a very naive example, but imagine that 0 there is a performance optimization, or a bug fix. As long as the author didn’t change any of the above assumptions, your code would be OK with that.

What happens when things change?

What does it mean “your code would be OK with that”? Since when does code care about stuff? Since when does it have feelings? well, it doesn’t. When I said “your code would be OK with that” I meant a few things:

  • that the compiler/interpreter reading your code would be able to follow all the symbols correctly and nothing would be missing - it would cause an error at build time (with a compiler) or at run time (with an interpreter).
  • your code would send the wrong parameters to the API making the internal “black box” logic to fail - causing an error.
  • your code would get the wrong value from the API and your logic would fail - causing an error.
  • the result of your code is not the desired result anymore - no error though.

You could almost smell the common pattern here with there errors! but unfortunately, this isn’t the rule though, the rule could be described by

if a change in the black box causes you to change something in your code it means it’s a breaking change.

So this what your code won’t be OK with, stuff that causes it to change - because let’s face it, nobody likes change, not even code.

Now, let’s look at why, if any of the above assumptions changes - the code using add will be broken.

  • Imagine yourself writing const x = add(1,2);, obviously if the function is now named sum it means you need to change your code - breaking change.
  • Imagine you have the code const x = add(1,2); but now add is implemented this way:
function add(a, b, c) {
  return a + b + c;
}

You might need a little refresher but 1 + 2 + undefined results in NaN, which is a value doesn’t equal anything, not even itself - it’ll probably break your code after enough time.

  • Again, you have the code const x = add(1,2); you then probably use it for other computations or as an index to an array or something. But what if, x is no longer a number but an object. What would this mean myArray[x]? it would cast x to a string because javascript would think this is a property accessor not an index based accessor and you’d get an array with a property of [object Object] and the size hasn’t changed. I bet your app won’t function the same way as it did before after this kind of a change.
  • Last one, looking at this code const x = add(1,2); you’d expect x to have the value of 3, right? but what if this is how we decide to implement add now:
function add(a, b) {
  return a - b;
}

The result doesn’t make any sense anymore in the way it did before, it no longer adds numbers together, it actually subtracts them - the intent is now different.

You could easily find solutions to all of the above that don’t necessitate these breaking changes, but since add is so simple and unambiguous it’s hard to find valid use cases. But I bet you could think of a case where some library/framework you are using caused a breaking change.

It’s time we try to generalize these assumptions into categories.

  • We have signiture based assumptions.
  • We have definition based assumptions (is the intent the same?).

It’s important to make this distinction because signiture based assumptions are super easy to see while definition based assumptions aren’t. A compiler is “trained” to see them and make sure nothing is missing, and you can, theoretically, do the same thing too. What about definition based assumptions? can a compiler be “trained” for it? can it know that when you create a function named add and you implement it using the minus operator it means that it’s not doing what it should? in that case we should all be worried to be out of a job because the difference between this to machines writing the code themselves is not big at all.

definition based assumptions are harder to detect, but you, as the author of said intent, know when it is no longer valid - you know when it changes.

Now, it’s time we bring this back full circle back to a Component Library. With a component library API, as with any API, your API is signiture based. Also, big part of a component library API are the definition based assumptions.

Signiture based assumptions

Let’s take a look at some possible assumptions of an APIs that are signiture based. We’ll keep with the function metaphore because in React, one of the most popular component based frameworks, there’s a way to treat functions as components.

So, the parallels between a function and a component are:

  • function name -> component name
  • function parameters -> component props
  • function return type -> ?

The first two are fairly obvious, but the last one isn’t. What in components can we compare to a function return type? components have to always return the same type, a React element. We can however make a comparison to some display type of that React element. Imagine you have an element that has display: block, what happens when you place it next to another element? that element will take the entire width of the container causing the other element to appear below it. Same thing for display: inline, the other element would appear next to it. That’s the thing, the display property conveys two things, an outside display and an inside display. In this case we’re focusing on the outside display, what it means is that whenever you change the outside display of the returned element it’s like you’re changing the return type of the function because now your consumers will need to make sure that this new display makes sense everywhere they used it.

To recap:

  • function name -> component name
  • function parameters -> component props
  • function return type -> component top level element outside display

P.S. It doesn’t just have to be display, it can also be position or any other thing that affects layouting.

Now, for the fun part - definition based assumptions.

Definition based assumptions

Let’s take a look at some possible assumptions of an APIs that are definition based.

First, let’s think back at our basic API definition of the add function. What was the definition based API there? It was the fact that add returned the result of adding up the two numbers coming from the inputs. This API was not part of the signiture, you can have many menthods with the same signiture, but this is the way to actually make it unique - what you define it to be.

It’s sort of an abstract way to look at what goes on under the hood, with the add method what actually goes on under the hood is the usage of + which is exactly how we want to define this component - adding up numbers.

So, how do we define a component in terms of API? Let’s look at what a component actually does. Let’s look at a Card example:

<section className="Card__container">
  <button className="Card__menu-button">
    <svg className="Card__menu">...</svg>
  </button>
  <img className="Card__preview" src={props.image} />
  <h3 className="Card__title">{props.title}</h3>
  <p className="Card__description">{props.description}</p>
</section>

So what does this component do? It shows a menu button, an image for the card and the title & description of the card. But, are these specific class names part of it? are these specific elements? probably not. I could change it to this and it would probably look & behave the same (need to apply some aria, but that’s about it):

<div className="CustomCard__container">
  <input type="button" className="CustomCard__menu-button">
    <svg className="CustomCard__menu">...</svg>
  </input>
  <img className="CustomCard__preview" src={props.image} />
  <div className="CustomCard__title">{props.title}</div>
  <span className="CustomCard__description">{props.description}</span>
</div>

So, we practicaly changed everything but the component is still the same, so what is this API then? Actually, I was kind of cheating, this API was never there to begin with. Since we showed all of the above are technically changable without sacrificing a whole lot, we need to add something, something that is of meaning to this component, that’s not going to change.

I’m unintuitively talking about data- attributes.

These attributes are not recognized by the HTML parser, they are actually reserved for our needs, so we do what ever we want with them. We can define them ourselves. We can create a new attribute called data-element or data-testid (you’ll understand why in a minute). These attributes would define the key elements that our component defines. Something like:

<div className="CustomCard__container">
  <input
    data-testid="Card__menu"
    type="button"
    className="CustomCard__menu-button"
  >
    <svg className="CustomCard__menu">...</svg>
  </input>
  <img
    className="CustomCard__preview"
    data-testid="Card__image"
    src={props.image}
  />
  <div className="CustomCard__title">{props.title}</div>
  <span className="CustomCard__description">{props.description}</span>
</div>

Notice that we only added data-testid on two elements, the button and image. To see why we’ll first take a look at why we didn’t add it on the other elements.

  • The container - that’s just an element to wrap the rest of it, it’s just the mechanics of the lanuage not something specific to the implementation.
  • The svg - it’s just an image representing the menu, it’s not actionable in any way within our component.
  • The title & description - these are in the responsibility of the parent element, they can pass in whatever they want, these elements are basically just for styling.

As you can see, these parts of our component are just mechanics to get us from point A to point B, they don’t actually convey something specific of what it means to be a Card.

Now, let’s look at the other elements, the ones we actually added data-testid to.

  • The button - this element is actionable, it probably affects the state of the Card, its part of the card definition, an action menu.
  • The img - this element uses a prop from outside an applies it in some way within it, it’s verifiable in the sense that the card put it in the right place.

The two keywords here are actionable & verifiable.

If parts of your component are actionable by a user, they are also part of your API, the main reason for that is testing. It takes a lesson from react-testing-library in a sense that your test should reflect the way your user would use that component, so if a user can act upon an element, your test should too. This means that you need to be able to query within the component to get the button without relying about coding/naming conventions or anything else, you should only rely on what the component defines for you - data-testid. This is the contract between you, the consumer of the component, and author that this part won’t change no matter what, but if it does - it’s a breaking change. Why is it a breaking change you ask? because tests depend on it, and if it changes, you guessed it, your code won’t be OK with that.

Same goes for parts of your components that are verifiable because that’s the relevant information that the user sees on screen that’s the responsibility of the component and is rendered by it. That’s why we didn’t add the title & description to the API, because they’re not the responsibility of this component, for all we know they could be elements that have data-testid of their own like this:

<Card
  image="https://my.cusstom.image"
  title={<div data-testid="My_Card_Title">Crazy title!</div>}
  description={
    <p data-testid="My_Card_Description">
      This is my awesome card description!
    </p>
  }
/>

You need to think about what truly represents your components in the sense of actionable & verifiable elements - these are the things that will be part of your API. You don’t have to put data-testid attributes on them, you just need to make sure you understand that people need them because they represent the essence of your component.

API and maintainability

As I said in the begining, your main goal is maintainability. The reason defining an API so clearly helps maintainability is because you know what you can change and what you can’t.

Defining a clear API helps by:

  • Less code to read - with definition based API you can remove all the things that aren’t part of your definition.
  • Easier to fix bugs - with signiture based API you can tackle bugs more easily, you can either reproduce them or not based on the signiture, the rest of the stuff doesn’t matter.
  • Easy to extend - All you need to do is determine whether this extension is for the definition based API, signiture based API or both. Just be careful, the more you extend the more code you need to maintain. At some point you’ll naturally want to split components up to gain that maintainability back.

The API gives you clear bounderies of what the consumers of your component are using and how it will affect them if you change something in the API. The rest of the code? do whatever you want with it. you can replace some of it with an external library, or refactor it to be up to date with the latest ECMA standard, it’s your call. You can now think more about your code and less of how it might break.

Let’s finish up

We talked about signiture based API and definition based API, these two would probably take you far with your component APIs. The signiture based APIs are the component name/props/element layout - think how would a compiler differentiate it from another component and how you would render it specifically. The definition based APIs are the specific inner workings of the component - think how a user would interact with your component (actionable & verifiable).

There might be other types of APIs in a component based architecture that you might need to add into the mix. See what works for you and iterate along the way, nothing is perfect from the begining - you have to try to know what works and what doesn’t.