Recently, I was working on a side project and I found at a small lib to generate avatars called react-nice-avatar. This library is really awesome because it allows me to generate avatar for my users like this:

Avatar generated by react-nice-avatar

But then, I wanted to move away from React and maybe build a version of the same application using another frontend framework (just for the sake of testing different way).

The generated Avatar logic is strateforward: it’s just conditional component to display or not:

// https://github.com/dapi-labs/react-nice-avatar/blob/main/src/hair/index.tsx
export default function hair(props: { style: string, color: string, colorRandom: boolean }): SVGElement {
  const { style, color, colorRandom } = props;
  switch (style) {
    case "thick":
      return <Thick color={color} colorRandom={colorRandom} />;
    case "mohawk":
      return <Mohawk color={color} colorRandom={colorRandom} />;
    case "womanLong":
      return <WomanLong color={color} />;
    case "womanShort":
      return <WomanShort color={color} />;
    case "normal":
    default:
      return <Normal color={color} />;
  }
}

So, it could be easily ported to any JSX rendering engine (Solid.js, Preact, React, etc..), no?

I wanted to give a try!

TLDR: you can build a monorepo, expose some JSX components and then

Why?

Why don’t simply use any framework and wrap it into a Web Component (like React to Web Component)?

It means shipping the React engine in the web component and that has a huge cost: React is big.

Why don’t you simply use a Javascript template library like EJS or nunjucks?

Yes, it could have been a solution but templates languages are easier to maintain in separated files which make it not compatible on frontend. Templates can be defined in the Javascript code but it become a nightmare to maintain it.

Why do you do this, Isn’t it too much for a side project? Rewriting your project does not provide value to your APP.

Yes, it’s true. But it’s mainly to experiment Monorepo & other libraries.

Objectives

  1. provide it to as many as possible JSX engines (Solid.js, Preact, React, and others)
  2. export it as as Web Component
  3. export a render function to use it in any backend framework without frontend library

Let’s code

So we’ll start a new monorepo using NPM workspace and export a few libraries:

// package.json
{
  "name": "@nice-avatar-svg/monorepo",
  "private": true,
  "workspaces": ["element", "preact", "react", "solid", "render", "shared"]
}

Let’s have a look at our workspaces.

share package

This library will contains our JSX components. The Javascript engine can’t interpret JSX directly, so we can’t use it directly in your project. It needs to be transpiled by a bundler (like Vite, Rollup or Webpack) to compile it to plain Javascript interpreted by a library (like Solid.js, React or Solid.js). You can check this Babel example.

But, in our case, NPM doesn’t forbid to export whatever format you want. So we still can export JSX files.

In my situation, I just exposed this tree:

shared
├── components
│   ├── Body.jsx
│   ├── EarAttached.jsx
│   ├── EarDetached.jsx
│   ├── ...
│   ├── EyebrowsDown.jsx
│   ├── ShirtCollared.jsx
│   ├── ShirtCrew.jsx
│   └── ShirtOpen.jsx
├── constants.mjs
├── model.mjs
├── package.json
└── README.md

The package.json is strateforward:

// shared/package.json
{
  "name": "@nice-avatar-svg/shared",
  "type": "module",
  "types": "model.mjs",
  "version": "0.0.1",
  "private": true,
  "license": "ISC"
}

And that’s it for now.

preact library

Ok, now we have our components, the goal is to use it and export it for Preact.

We just have to bootstrap a Preact project using Vite:

npm create vite@latest
✔ Project name: … @nice-avatar-svg/preact
✔ Select a framework: › Preact
✔ Select a variant: › JavaScript

We simply create a new component named NiceAvatar, then import our parts, assemble them and export it:

// preact/NiceAvatar.jsx
// ...
const EarAttached = lazy(() => import("@nice-avatar-svg/shared/components/EarAttached"));
const EarDetached = lazy(() => import("@nice-avatar-svg/shared/components/EarDetached"));
const EarRingHoop = lazy(() => import("@nice-avatar-svg/shared/components/EarRingHoop"));
// ...

/**
 * @typedef {import('@nice-avatar-svg/shared/model.mjs').AvatarConfiguration} Props
 * @param {Props} props
 */
export default function NiceAvatar({
  bgColor = COLORS.Azure,
  earSize = "small",
  skinColor = COLORS.Apricot,
  shape = "circle",
  // ...
}) {
  return (
    <Layout shape={shape} bgColor={bgColor}>
      <Body skinColor={skinColor} />
      <Suspense fallback={<LoadingNode />}>
        <Ear earSize={earSize} skinColor={skinColor} />
      </Suspense>
      {/* ... */}
    </Layout>
  );
}

const LoadingNode = () => <></>;

/** @param {Pick<Props, 'earSize' | 'skinColor'>} param0 */
function Ear({ earSize, skinColor }) {
  switch (earSize) {
    case "big":
      return <EarDetached skinColor={skinColor} />;
    case "small":
      return <EarAttached skinColor={skinColor} />;
    default:
      return <></>;
  }
}
// ...

Now we just have to enable Vite to expose ou component in vite.config.js

// preact/vite.config.js
// ...
export default defineConfig({
  // ...
  build: {
    target: "esnext",
    lib: {
      name: "@nice-avatar-svg/preact",
      entry: fileURLToPath(new URL("./NiceAvatar.jsx", import.meta.url)),
    },
  },
});

And we specify in package.json to export this component and import it @nice-avatar-svg/shared

// preact/package.json
{
  "name": "@nice-avatar-svg/preact",
  // ..
  "module": "./dist/preact.js",
  "main": "dist/preact.umd.cjs",
  "exports": ["./NiceAvatar.jsx"],
  "devDependencies": {
    "@preact/preset-vite": "^2.8.2",
    "vite": "^5.0.11"
  },
  "peerDependencies": {
    "preact": ">=10.0.0"
  },
  "dependencies": {
    "@nice-avatar-svg/shared": "*"
  }
}

Then check it works building the project using npm run build.

Testing it

Just to makes sure it works properly, we’ll bootstrap and another project and test it quickly.

First, we initialize the linking process

npm link

Create a new Vite project with Preact

cd /tmp && npm create vite@latest
✔ Project name: … vite-project
✔ Select a framework: › Preact
✔ Select a variant: › JavaScript

Then we link our package:

npm link @nice-avatar-svg/preact

To link a local dependency, you simply need to run npm link in preact/ folder and then update the src/app.jsx file

import NiceAvatar from "@nice-avatar-svg/preact";
import { Suspense } from "preact/compat";

export function App() {
  return (
    <Suspense fallback={"Loading..."}>
      <NiceAvatar />
    </Suspense>
  );
}

The run npm run dev and voilà! As you can see, everything work properly. The good part is the build

npm run build

> vite-project@0.0.0 build
> vite build

vite v5.2.11 building for production...
✓ 48 modules transformed.
...
dist/assets/MouthSmile-DHK0m80w-DOpgu1La.js              0.23 kB │ gzip:  0.20 kB
dist/assets/NosePointed-DzhHrocg-CWPme9UT.js             0.25 kB │ gzip:  0.21 kB
dist/assets/NoseCurve-CdVIF_wC-Bd18D9Fz.js               0.26 kB │ gzip:  0.22 kB
...
dist/assets/index-DcyXC5km.js                           47.15 kB │ gzip: 13.08 kB
  1. every components are lazy loaded and split into smaller chunks
  2. Preact is bundled once, we reuse the same engine

react & solid libraries

As you might guess, this is the exact same steps

  1. create a vite project
  2. introduce the component (which is almost a copy/paste of the NiceAvatar.jsx component)
  3. export it through vite.config.js & package.json

But then you realize that we have a lot’s of difference with react/NiceAvatar.jsx and all other */NiceAvatar.jsx. Only two things changes: lazy & Suspens.

So it’s time to introduce a builder function which take them as argument and return a function:

// shared/builder.jsx
import Body from "@nice-avatar-svg/shared/components/Body";
import Layout from "@nice-avatar-svg/shared/components/Layout";
import { COLORS } from "@nice-avatar-svg/shared/constants.mjs";

/**
 * @param {object} param0
 * @param {(import: any) => JSX.Element} param0.lazy
 * @param {JSX.Element} param0.Suspense
 */
export default function builder({ lazy, Suspense }) {
  const EarAttached = lazy(() => import("@nice-avatar-svg/shared/components/EarAttached"));
  const EarDetached = lazy(() => import("@nice-avatar-svg/shared/components/EarDetached"));
  // ...

  /**
   * @typedef {import("./model.mjs").AvatarConfiguration} Props
   * @param {Props} props
   */
  return function NiceAvatar(
    {
      /* ... */
    }
  ) {
    return (
      <Layout shape={shape} bgColor={bgColor}>
        <Body skinColor={skinColor} />
        <Suspense fallback={<LoadingNode />}>
          <Eyebrows eyebrowsStyle={eyebrowsStyle} />
        </Suspense>
        {/* ... */}
      </Layout>
    );
  };
}

And now our React component become really simple

// react/NiceAvatar.jsx
import builder from "@nice-avatar-svg/shared/builder";
import { Suspense, lazy } from "react";

const NiceAvatar = builder({ lazy, Suspense });

export default NiceAvatar;

element library

Fairly easy too, we just create a new package which import two libs: the preact version and preact-custom-element

// element/package.json
{
  "name": "@nice-avatar-svg/element",
  // ...
  "dependencies": {
    "@nice-avatar-svg/preact": "*",
    "preact-custom-element": "^4.3.0"
  }
}

Then we just have to expose our

import register from "preact-custom-element";
import NiceAvatar from "../preact/NiceAvatar";

register(
  NiceAvatar,
  "nice-avatar",
  [
    /* props */
  ],
  { shadow: false }
);

Testing it

Again, you can reuse our /tmp/vite-project to test it quickly.

Simply import the module, and instantiate the custom element.

<!DOCTYPE html>
<html lang="en">
  <!-- ... -->
  <body>
    <!-- ... -->
    <nice-avatar />
    <script type="module" src="node_modules/@nice-avatar-svg/element"></script>
  </body>
</html>

render library

Last step, our render function! This one might be useful for rendering it on server side.

We’ll again reuse the preact/ version and use preact-render-to-string library.

// render/package.json
{
  "name": "@nice-avatar-svg/render",
  // ...
  "dependencies": {
    "@nice-avatar-svg/preact": "*",
    "preact-render-to-string": "^6.4.2"
  },
  "devDependencies": {
    "@nice-avatar-svg/shared": "*"
  }
}

And we simply export a single function

// render/index.mjs
import NiceAvatar from "@nice-avatar-svg/preact";
import { renderToStringAsync } from "preact-render-to-string";

/**
 * @param {import('@nice-avatar-svg/shared/model.mjs').AvatarConfiguration} props
 */
export default function render(props) {
  return renderToStringAsync(NiceAvatar(props));
}

and cover it using a quick test

// render/index.spec.mjs
import assert from "node:assert";
import { it } from "node:test";
import render from "./index.mjs";

it("should render the avatar", async () => {
  const svg = await render({});
  assert.ok(svg.startsWith("<svg"));
});

Publish everything

Now everything is tested, we’re ready to publish our libraries to NPM!

Let’s bump every packages to 1.0.0 first using npm version:

npm version major --workspaces --include-workspace-root
@nice-avatar-svg/monorepo
v1.0.0
@nice-avatar-svg/element
v1.0.0
@nice-avatar-svg/preact
v1.0.0
@nice-avatar-svg/react
v1.0.0
@nice-avatar-svg/solid
v1.0.0
@nice-avatar-svg/render
v1.0.0
@nice-avatar-svg/shared
v1.0.0

And then

npm publish --workspaces --access public

Conclusion

It was a fun experiment for me. I did learn that you don’t have to stick to React and a simple library can be translated to an another framework easily.

And now, just for the fun, let’s compare the bundle size bewteen Solid.js, Preact & React:

solid  : ==========> 77K
preact : ==============> 91K
react  : ======================================> 174K

The whole repository is on Github at github.com/madeindjs/nice-avatar-svg and on NPM.

See other related posts