Exporting the same JSX library to React, Preact & Solid
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:
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
- provide it to as many as possible JSX engines (Solid.js, Preact, React, and others)
- export it as as Web Component
- 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
- every components are lazy loaded and split into smaller chunks
- Preact is bundled once, we reuse the same engine
react
& solid
libraries
As you might guess, this is the exact same steps
- create a vite project
- introduce the component (which is almost a copy/paste of the
NiceAvatar.jsx
component) - 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
Why User defined Type Guard is a really powerful feature of Typescript