How I built a cross-framework frontend library
Tom Österlund
Posted on January 17, 2024
TL;DR
- Web-frontends today are built using an ever-increasing number of frameworks. Vendor lock-in is a real problem.
- Writing cross-framework frontend libraries does not have to be complex. It merely requires careful planning.
- Teleport / Portals are key for giving the implementer freedom to customize.
This article will discuss the general concept of building a cross-framework frontend library. It will also display some examples of how this was applied when building the event calendar Schedule-X.
The problem with framework-overflow
If you start building a frontend web-application today, one of the first technical decisions you will have to take is which framework to choose. Probably you will end up with one of the 3-4 most popular ones, but there are so many out there.
Now, I don't agree that this would be inherently bad. People come up with a lot of different solutions to similar problems all the time. People are essentially different, and will prefer different solutions. You're never going to convince me that mass producing Mercedes-Benz and Peugeot was unnecessary, just because Henry Ford had already started mass producing cars before them. Similarly: Vue, React and Angular do not make newer options redundant.
One of the challenges that do arise due to this abundance of frameworks, is a thing called vendor lock-in; you pick one framework, and chances are high you will stick to this for the duration of the project. Even if the project lasts for many years.
This too, does not necessarily need to be a problem. If the framework thrives, and its eco-system does too, this does not matter. When it does matter, is when the framework you chose, or its surrounding eco-system, starts slowing down. Your company might need a whiteboard software. But all the cool whiteboard projects out there are only compatible with all the other frameworks, not yours. What do you do?
Writing cross-framework libraries is not that hard
Some of the problems that arise due to vendor lock-in, could be resolved if more libraries chose to work without frameworks. Here is an illustration of how this can work conceptually:
OK, what is going on in this image? Let us go through this architecture piece by piece.
1. The library core
At the very bottom of the image, there are 3 blocks that I chose to call application components. If you are building a cross-framework library, these can be built with whatever tools you want! Only catch is, all the tools you use to build it, will be needed by everyone consuming it. So choose wisely, and be mindful of how many kilobytes of third party code you will need in order to ship. In Schedule-X, I chose to use Preact. You will probably be fine with most lightweight virtual DOM libraries, and just like with frameworks there are a few to pick from.
2. Pure JS/TS function or object
In order to be consumed by different types of frameworks, your library will need to be compiled to regular JavaScript. So in the end, after you have run your build scripts, the result you need is a vanilla JavaScript function or object, which under the hood mounts your application. Here is how I did this in Schedule-X:
import { createElement, render } from 'preact'
import CalendarWrapper from './components/calendar-wrapper'
import CalendarAppSingleton from '@schedule-x/shared/src/interfaces/calendar/calendar-app-singleton'
export default class CalendarApp {
constructor(private $app: CalendarAppSingleton) {
}
render(el: HTMLElement): void {
render(createElement(CalendarWrapper, { $app: this.$app }), el)
}
}
I here have a very slimmed down class, CalendarApp
, with a render
method. All the render
method is doing, is call the render
function of Preact, mounting my calendar to the DOM. Anyone instantiating this class and calling the render
method with an element passed to it, will get a calendar to consume.
3. Consuming the library
The last puzzle piece of the image above is the consumer-piece.
After I have compiled this TypeScript file above into JavaScript, anyone executing JavaScript in a modern browser can consume it: React, Angular, Vue: they can all run it. At this point, we already have a framework agnostic library, ready to be consumed by many frontend teams, regardless of their choice of tooling. One important piece is missing though.
Giving the implementer ultimate freedom
So you have built your frontend library. You spent countless hours on perfecting the looks and feel of it. Every pixel looks perfect to you. Still, if you're going to offer this library to the multitudes, chances are it won't fit into the design system others are working with.
I, for example, built the Schedule-X calendar leaning heavily towards Material Design 3. While this might work for some, it surely won't work for all. It is therefore important that I offer implementers a way of customizing what shows up in the calendar. Ultimately, it would be nice if implementers of my library could replace some components of my code with their own. This can seem like a daunting task at first. It sure did to me. Nevertheless, the solution does not have to be very complex.
In the example below, I will demonstrate how this can be done with React, but the same principles apply to any framework where UI components have reactive state that triggers DOM changes.
Here's how it works, broken down into 4 steps. Each of the steps below correlate to the same number in the image above.
There needs to be a React component, let us call it the
ReactAdapter
component. This component has a rendering function like all others, which places some content in the DOM. Most importantly, this component has acustomComponentFn
function. We can pass this function as part of the configuration to the library components. Also note that the function receives an argument:element
.In the presence of
customComponentFn
, our application component notices that we want to render som custom stuff. Doing so, instead of rendering its own content, it just callscustomComponentFn
. Here, we also pass an element, which is empty for now, but sits exactly in the place of the DOM where we want to render some custom content.In the
customComponentsMeta
variable of our React component, ourcustomComponentFn
now saves some metadata about the custom component we want to render, as well as the element it should be rendered onto.Updating the state of
ReactAdapter
causes our Reactrender
function to run again. This time around, we have some metadata about a custom component and the element to render it to. UsingcreatePortal
(in Vue this would be Teleport) we can now render our custom component.
Voila! We have built a cross-framework library.
Curious for more?
If these concepts sound interesting to you, but you need more precise examples of how this actually pans out in code, you can check out the actual source code that this article is based on:
React implementation: https://github.com/schedule-x/react/blob/main/src/schedule-x-calendar.tsx
Application component calling a
customComponent
function provided by React: https://github.com/schedule-x/schedule-x/blob/main/packages/calendar/src/components/week-grid/date-grid-event.tsx#L79
Posted on January 17, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.