Defining the API of a Component Library
Posted on March 16, 2019 -  đ©đœâđ»Â 15 min readComponent 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
, notaddition
orsum
. - 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 namedsum
it means you need to change your code - breaking change. - Imagine you have the code
const x = add(1,2);
but nowadd
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 meanmyArray[x]
? it would castx
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 expectx
to have the value of 3, right? but what if this is how we decide to implementadd
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 add
s 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.