'Creating Custom UI Library' post illustration

Creating Custom UI Library

avatar

When creating a UI library is inevitable

Imagine you have a commercial product that is growing expansively, let’s take a taxi app, for example. Everything is going well; new features are being added in no time, the app is growing bigger and bigger, and now you have an admin panel, web pages, and two mobile apps.

When the UI library isn’t enough

At this point, you start noticing that development speed is going down. Although you have used ready-to-go UI kits to speed up development, sticking to the design is becoming harder and harder. Every new component needs to be adjusted to conform to the overall layout. It takes more and more time to fix simple UI bugs, changing the layout requires going to several projects, and common pages are written in different ways, which impedes development; in other words, maintaining a simple UI layout becomes a nightmare. It’s a common thing in a new project, but to let it grow, it’s inevitable to close this technical debt.

Divide and conquer!

You decide to recall the DRY principle and apply it here. Having done a little investigation, turns out there are lots of components that just repeat here and there across several projects, so it’s high time you gathered them together to make life easier. You extract all the common components and reuse them from another repository. Bingo! Now, some minor changes to the design require only changing components in the UI repository and updating packages in the package.json file!

Benefits of custom UI library

Having extracted common components, we can now brood a little about the point of it and other possible benefits:

  • Faster development - now adjusting the design is no longer a problem, as we can do it in a single repository;
  • Reusability - new components created in the UI library repository can be used in all the other projects;
  • Theming - creating a single theme allows you to make the whole project look familiar and consistent.

Speeding up the development

Highly satisfied with the work you have done, you are spotting another problem - when adding a new feature, you have to do a ton of updates and publish to the npm registry just to see how things work. Every minor change requires creating a pull request, merging it to the main branch, publishing it in the npm repository, and pulling the latest updates on the project. Doesn’t sound fascinating, does it? Luckily, we have a solution just for that!

Hot reload from external repository

To simplify things, we decided to use an astounding tool that watches for changes in the UI library, creates a build, and replaces it in node_modules in other applications. This tool is called Turborepo, and it makes development blazingly fast! With a minimum setup, we have several distinct repositories working together seamlessly without needing to roll out the new version in the npm registry constantly. If you don’t like Turborepo, there is a plethora of other tools; take Lerna, for example. In its basics, it just creates a virtual mono repository out of several distinct repositories.

Analyzing own experience or when you might need a custom UI lib

Having dealt with creating libraries, we can now highlight key points when considering splitting code into packages:

  • Does your product have multiple digital touchpoints (websites, web apps, mobile apps) that would benefit from a consistent interface design? If yes, this is definitely the path to choose from the get-go to avoid work, which will just snowball over time.
  • Does your product team need to release changes and new features quickly? If you face a constant struggle to make adjustments to the code base, then you should also consider the option of switching to a custom UI library. With the help of tools like Turborepo, the development will become faster and easier to pursue.
  • Do you plan on introducing yet another application to the project? Then reusing basic components from the UI library will be a must-have as it will save time both configuring the theme and creating all the required components to start the development.

Structuring the project

After a while, your project has grown; consequently, amount of components increased, as did the size of the overall UI library, and you foresee another nightmare to overcome - folder structure has become so entangled it’s hard to make out who is who. Let’s take an example:

1
2
3
4
5
6
7
.
├── src
   ├── components
      ├── Button
      ├── Navbar
      ├── PhoneInput
      ├── Modal

As you may have noticed, the design is disastrous. Absolutely different components are just cramped up in the components folder, there is absolutely no division here. It may not affect consumers of the library, but maintenance will be a great endavour in the near future, that’s for sure.

Borrowing from nature - atomic design

As nature is a complex and evolving organism that manages to survive despite difficulties, we may overlook some core concepts to make our UI library more scalable and resistant to such chaos by introducing Atomic design. It facilitates easier development by splitting components into three main groups: atoms, molecules, and organisms.

How does it help?

Well, when you have your components divided into groups, it makes it intuitive where one belongs, and skimming through a project becomes more of consulting a schema than randomly looking for components in the hope that you might not overlook them.

Anti-patterns

Beware of the temptation to squeeze each and every complex component into an organism group. Initially, the UI library is designed to solve common problems by providing sets of elements, but if you start putting there the components that solve the whole domain problem, like a login form or profile page, it won’t do you any good except bloating the codebase with redundant very specific code.

Final look

Now that you managed to use Atomic methodology, your library is starting to be of the right shape:

1
2
3
4
5
6
7
8
9
10
11
.
├── src
   ├── components
      ├── atoms
          └── Button
      ├── molecules
          └── PhoneInput
      ├── organisms
          └── Modal
      ├── layout
          └── Navbar

Let’s have some hands-on experience

It’s always better to acquire some muscle memory in a new area, so let’s give it a try and hit some keys! If you just want to observe the code, here is the link for the source code - click

Start from scratch

It’s always a good idea to save some time and start from some template, so I decided to use a ready-to-go template from Turborepo, here is the link. Having downloaded the code, you are now provided with working code for the web and mobile platforms. Let’s take a look at folder structure here, at its main part essentially:

1
2
3
4
5
6
.
├── apps
   ├── web
   └── native
└── packages
    └── ui

We can see the common structure of mono repo:

  • Apps gathered together in app folder;
  • Core packages in packages folder.

Detailed view

If you are curious about what Turborepo does under the hood, it’s fairly simple. With the help of the NPM Workspace feature, you can use one local project as a dependency for the other therefore saving time for the sake of development. If we try to start the project and look in the root level node_modules, we indeed see the @repo directive representing the packages folder.

1
2
3
4
.
├── @repo
   ├── config
   └── ui

Setting the right folder structure

If we peek into the packages/ui folder, we will see a common structure like this:

1
2
3
4
.
├── src
   └── button.ts
└── package.json

As we already know about Atomic architecture, let’s tidy it up and make it more flexible for future changes:

1
2
3
4
5
6
7
8
9
.
├── src
   ├── components
      ├── atoms
          └── Button
      ├── organisms
          ├── Modal
          └── DatePicker
└── package.json

Platform targeted builds

As you can see, currently, we have an architecture that is churned out to work with only cross-platform libraries like react-native-web, but what if we want more flexibility and use the whole power of the JS community?

To do this, we have to do a little bit of work, to be exact:

  1. Switch from tsup to webpack
  2. Refine webpack config for adaptive bundles
  3. Use declaration files in cross-platform components

Sounds not easy, eh? Take a look!

Migrating to webpack

Migrating to webpack is not such hard work, we have only to change scripts in package.json in packages/ui to run webpack instead of tsup. I guess, by the look of --env PLATFORM=mobile, you have got a whiff of where it’s going!

1
2
3
4
5
6
7
"scripts": {
    "build:web": "webpack --env PLATFORM=web -c webpack/platform.config.ts",
    "build:mobile": "webpack --env PLATFORM=mobile -c webpack/platform.config.ts",
    "build": "webpack -c webpack/platform.config.ts",
    "dev": "webpack --watch --env PLATFORM=$PLATFORM -c webpack/platform.config.ts",
    "clean": "rm -rf dist"
  },

And add basic config:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import path from "path";
import { Configuration } from "webpack";

export const externals = ["react-native", "react", "react-dom"];

export const platformsExtensions = {
  web: [".web.ts", ".web.tsx"],
  mobile: [".native.ts", ".native.tsx"],
} satisfies Record<string, string[]>;

const config = (env) => {
  const distPath = path.resolve(__dirname, "../dist");

  const platform = env.PLATFORM as keyof typeof platformsExtensions;

  if (!platform) {
    throw new Error("Specify the bundle platform!");
  }

  const platformExtensions = platformsExtensions[platform];

  return {
    entry: "./src/index.tsx",
    mode: "development",
    devtool: "inline-source-map",
    output: {
      filename: "index.js",
      path: distPath,
      library: { type: "commonjs" },
    },
    externals,
    module: {
      rules: [
        {
          test: /ts/,
          loader: "ts-loader",
          options: {
            compiler: "ttypescript",
            onlyCompileBundledFiles: true,
          },
        },
      ],
    },
    resolve: {
      extensions: [".ts", ".tsx", ...platformExtensions],
    },
  } satisfies Configuration;
};

export default config;

There are a few moments to be aware of, like:

  • Externals - webpack configuration telling which packages do not bundle but require a consumer to have it installed.
  • Resolve.extensions - a really awesome feature of Webpack that allows us to pinpoint which files we want to bundle and which to leave out from the build. Pay close attention to the platformExtensions array, which specifies platform extensions like .web or .native and is chosen dynamically based on the env variable PLATFORM.
  • Module.rules, which allow us to use a custom loader for specific modules. In this case, I used ts-loader because of its flexibility in settings, especially the onlyCompileBundledFiles flag, which tells the compiler to generate .d.ts declarations only for files that will be bundled (resolve.extensions manages to help with that).

Make it all work

Lastly, in order to perform the magic, we need to do some advanced hacky movements and create certain architecture so that our IDE knows all about types and webpack can bundle components without questions. Firstly, in the folder where we are going to have platform-specific components, we need to structure it like this:

1
2
3
4
5
.
├── modal
   ├── index.d.ts
   ├── index.native.ts
   ├── index.web.ts

How is that even possible that we have 3 index files in one folder, and everything is supposed to work? Firstly, by default, JS looks specifically for files with the extension .js (.ts in our case), not with something like .d.ts, .native.ts, and .web.ts. Hence it won’t even see those files. Secondly, index.d.ts is just a declaration file that helps you as a developer to consume components by knowing their interface; it won’t end up doing anything. Thirdly, recall the platformExtensions array, which was defined in webpack.config.ts, its purpose is to select one of the index files depending on the platform, so in the end, we will only have one index file that will have a real impact on our code, and that is going to be bundled to the dist folder.

Ways to skin the cat

During this active development, everything abruptly halts to a stop due to the index.d.ts files. Because of the way the typescript compiler works, it doesn’t include d.ts files in the final build. Consequently, there will be absolutely no autocomplete, and IDE will argue about missing components in the library. We’ve come up with several solutions to this.

Webpack plugin

Simply including the .d.ts file in resolve.extensions in webpack config and merely copying those files to the dist folder won’t improve the situation. Webpack will start to behave incorrectly, either by just taking only .d.ts files or only .ts files. Fortunately, there is a solution just for that - create a webpack plugin that renames all declaration files with .web or .native extensions and solves the problem.

1
2
3
4
5
6
//Custom webpack plugin
plugins: [
      new AfterBundlePlugin({
        callback: () => removePlatformExtensions(distPath),
      }),
    ],
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// plugin
type Params = {
  callback: () => void;
};

export class AfterBundlePlugin {
  constructor(private readonly params: Params) {}

  apply(compiler) {
    compiler.hooks.done.tap(
      "bundle-plugin",
      (
        _stats /* stats is passed as an argument when done hook is tapped.  */
      ) => {
        this.params.callback();
      }
    );
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import fs from "fs";
import path from "path";

export const removePlatformExtensions = (dir: string) => {
  const files = fs.readdirSync(dir);
  files.forEach((file) => {
    const filePath = path.join(dir, file);

    const stat = fs.statSync(filePath);

    if (stat.isDirectory()) {
      // Recursively traverse subdirectories
      removePlatformExtensions(filePath);
    } else {
      // Rename files with extension .d.web.ts or .d.native.ts to .d.ts
      if (file.includes(".web")) {
        const newFileName = file.split(".web").join("");
        const newPath = path.join(dir, newFileName);
        fs.renameSync(filePath, newPath);
      }

      if (file.includes(".native")) {
        const newFileName = file.split(".native").join("");
        const newPath = path.join(dir, newFileName);
        fs.renameSync(filePath, newPath);
      }
    }
  });
};

Multiple bundling

Another solution is to create another webpack config types.config.ts file and tell it to copy .d.ts files only. Essentially, creating a config for moving all declaration files from the src folder to the dist. The types.config.ts will look like this.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import path from "path";
import { Configuration } from "webpack";

export const externals = ["react-native", "react", "react-dom"];

const config = () => {
  const distPath = path.resolve(__dirname, "../dist");

  return {
    entry: "./src/index.tsx",
    mode: "development",
    devtool: "inline-source-map",
    output: {
      filename: "index.js",
      path: path.resolve("dist"),
      library: { type: "commonjs" },
    },
    externals,
    module: {
      rules: [
        {
          test: /\.d\.ts$/,
          loader: "file-loader",
          options: {
            name: (name) => {
              return name.split("src")[1];
            },
          },
        },
        {
          test: /(?<!d).tsx?$/, // negavtive look behing to ensure this is not .d.ts file
          loader: "ts-loader",
          options: {
            compiler: "ttypescript",
            onlyCompileBundledFiles: true,
            compilerOptions: {
              declaration: true,
              declarationDir: distPath,
            },
          },
          include: /src/,
        },
      ],
    },
    resolve: {
      extensions: [".ts", ".tsx", ".d.ts"],
    },
  } satisfies Configuration;
};

export default config;

Then we should start webpack in parallel for types bundling config and for bundling components themself.

Coding sum up

We managed to set up a monorepository from scratch, which contains projects both for web and native platforms with our own UI library in place. Isn’t that beautiful?

Give it a run for its money

So now that you’ve implemented your custom UI library with scalable structure and cross-platform operability, let’s see what performing the usual library support tasks looks like.

Design tweaks

Let’s imagine some changes required for the existing Button component, and by changing it here, we expect the design to change everywhere else - in Navbar, possibly in PhoneInput and Modal.

New component

Now we have added a new feature that requires Toast component in place in every project. What a nightmare it would be if we didn’t have our custom UI library! But luckily enough, we can just add it here, and reuse it everywhere else without any problem, either by publishing to the npm registry or manually syncing node_modules by the help of Turborepo!

Theme variance

Now, if we want to add different themes like light mode, dark mode, or even a custom theme created by the user, it won’t be a pain. Instead of going to all of the repositories and manually tweaking every component to work with dark mode, we can redesign our library to wotk with theming instead.

Let’s call it a library

We hope our examples and explanation helped you decide whether to use a custom UI library and how to do it the most efficiently. If you are still unsure about some points, feel free to contact me, and be sure to get all the answers you require! Keep in touch!

If you need high level of customization on your current project,
we are always ready to help!