“Write Components Once, Compile to Every Framework” With Mitosis
What I learned while creating a GitHub Activity Calendar with Mitosis, which generates React, Vue, Svelte, and other components
TL;DR Here is the Git repo and npm package you can used instantly with
npm i activity-calendar-widget
. See more for usage with React (doc | demo), Vue (doc | demo), Svelte (doc | demo), Solid (doc | demo) and Qwik (doc | demo).
Preface
Recently, I’ve been researching how to create a UI library that can be used in various modern frontend frameworks, e.g., React, Vue, and Svelte. I came across Mitosis from Builder.io and tried to create a complex enough GitHub Activity Calendar widget as a POC. Though there are some hiccups, overall, it is a good experience. I want to take this chance to journal the process and my learnings. Why create a calendar widget? Because it helps to demonstrate some key functionalities:
- Able to accept inputs (Parent to Child), e.g., passing
data
and render the activity stat accordingly - Able to execute callbacks on events (Child to Parent), e.g.,
onClick
and notify the parent which day box is being clicked - Able to customize the theme, e.g., customize the date box from green to purple by CSS variables instead of inline passing a
color
props
Building a *Simplified* Activity Calendar
1. Outline the design
There are much more components and logic in the published components. However, to simplify this walkthrough, we will only create two main Mitosis components <Day />
and <ActivityCalendar />
.
<ActivityCalendar />
is our entry point, which accepts a data
array prop specifying each day’s activities. Regardless of whether there are activities, a <Day />
component will be rendered for each day.
.
├── preview
├── src/
│ ├── ActivityCalendar.lite.jsx
│ └── Day.lite.jsx
├── mitosis.config.js
└── package.json
The src
directory stores our Mitosis components.
The output
directory stores the generated components of the target frameworks. It can be customized in mitosis.config.js
.
The mitosis.config.js
file will be explained in step 3.
2. Initiate NPM and Install Mitosis
npm init -y
npm install @builder.io/mitosis-cli @builder.io/mitosis
3. Set up mitosis.config.js
/** @type {import('@builder.io/mitosis').MitosisConfig} */
module.exports = {
files: 'src/**',
targets: ['vue3', 'solid', 'svelte', 'react', 'qwik'],
dest: 'preview'
};
This is the Mitosis config JS file (You don’t say 😅). We can edit targets
to add/remove framework components to generate, e.g., preact
. Here’s Mitosis configuration doc, which contains all the available frameworks but does notice that some frameworks are in a very early stage (in fact, Mitosis itself is still in beta).
4. Set up the dev server?
Sadly, Mitosis doesn’t come with a Dev Server yet. In this walkthrough, you can try it out in Replit (edit the Mitosis components and generate native components) and preview the generated (e.g., React.js) components in Stackblitz.
In my local environment, I set up Astro to render multiple generated components on the same page. I will leave this to the next article.
5. <Day /> component
Here is the
<Day />
source code.
export default function Day(props) {
return (
<div
onClick={() => props.handleDayClick(props.dt)}
style={{
position: 'absolute',
width: '10px',
height: '10px',
backgroundColor: 'var(--day-box-color, #39d353)',
borderRadius: '2px',
top: props.top,
right: props.right,
opacity: props.opacity,
}}
></div>
);
}
<Day />
is a simple component that renders a rounded corner square 🟩, which is the day box in the activity calendar. Its position is determined by props.top
and props.right
. While the box color (how light the box is) is determined by props.opacity
. When clicked, the props.handleDayClick
function will be called with props.dt
, which contains the box’s date and time data.
6. <ActivityCalendar /> component
Here is the
<ActivityCalendar />
source code.
import { For } from '@builder.io/mitosis';
import Day from './Day.lite';
const getDays = (props) => {
// Generate 180 days from today
// Merge with the activities of specified days
const dataObj = (props.data || []).reduce((acc, cur) => {
return {
...acc,
[cur.date]: cur.activities,
};
}, {});
return new Array(180).fill(0).map((_, index) => {
const dt = new Date();
dt.setDate(dt.getDate() - index);
const year = dt.getFullYear();
const month = dt.getMonth() + 1;
const day = dt.getDate();
const id = year + '-' + month + '-' + day;
return {
id,
year,
month,
day,
dayOfWeek: dt.getDay(),
dayDiffFromToday: index,
activities: dataObj[id] || [],
};
});
};
const getDayRight = ({
dayDiffFromToday = 0,
dayOfWeek = 0,
}) => {
const todayDayOfWeek = new Date().getDay();
return (
(Math.floor(dayDiffFromToday / 7) +
(dayOfWeek > todayDayOfWeek ? 1 : 0)) *
14
);
};
export default function ActivityCalendar(props) {
return (
<div
style={{
position: 'relative',
display: 'inline-block',
width: Math.ceil(180 / 7) * 14 + 'px',
height: 7 * 14 + 'px',
}}
>
<For
each={getDays({
data: props.data ?? [],
weekStart: props.weekStart,
daysToRender: props.daysToRender,
})}
>
{(dt, index) => (
<Day
key={index}
handleDayClick={props.handleDayClick}
dt={dt}
opacity={(dt.activities.length + 1) / 10 + ''}
top={dt.dayOfWeek * 14 + 'px'}
right={
getDayRight({
dayDiffFromToday: dt.dayDiffFromToday,
dayOfWeek: dt.dayOfWeek,
}) + 'px'
}
/>
)}
</For>
</div>
);
}
The getDays
function generates an array with 180-day objects (180 is an arbitrary hard-coded value). If there are activities
on the specified date in props.data
, it will be merged to that specified day object. Regardless of whether there are activities, there will be a day object for each date, which will then render the <Day />
s.
In the <ActivityCalendar />
component, <For each={getDays()}> … </For>
, which is a Mitosis API, is used to render each of the <Day />
s. The props
that <Day />
needs are passed in a very similar React.js fashion.
Here is what props.data
looks like. There are activities on 2023–4–1 and 2023–3–26:
[
{
"date": "2023-4-1",
"activities": [
{ "title": "commit code" },
{ "title": "review pr" },
{ "title": "open issue" },
{ "title": "commit code" }
],
},
{
"date": "2023-3-26",
"activities": [{}, {}, {}, {}, {}, {}, {}, {}],
}
];
7. Generate the target components!
Now, the exciting moment! Let’s generate the target components by using the npm exec mitosis build
command. Since we specify our targets
frameworks as ['react', 'svelte', 'vue3', 'solid', 'qwik]
in mitosis.config.js
. The following screenshot is what we can expect:
8. See what it looks like in Vue and try out the onClick handler handleDayClick
<template>
<div
:style="{
position: 'relative',
display: 'inline-block',
width: Math.ceil(180 / 7) * 14 + 'px',
height: 7 * 14 + 'px',
}"
>
<template
:key="index"
v-for="(dt, index) in getDays({
data: data ?? [],
weekStart: weekStart,
daysToRender: daysToRender,
})"
>
<day
:handleDayClick="handleDayClick"
:dt="dt"
:opacity="(dt.activities.length + 1) / 10 + ''"
:top="dt.dayOfWeek * 14 + 'px'"
:right="
getDayRight({
dayDiffFromToday: dt.dayDiffFromToday,
dayOfWeek: dt.dayOfWeek,
}) + 'px'
"
></day>
</template>
</div>
</template>
<script>
import Day from "./Day.vue";
const getDays = (props) => {
const dataObj = (props.data || []).reduce((acc, cur) => {
return {
...acc,
[cur.date]: cur.activities,
};
}, {});
return new Array(180).fill(0).map((_, index) => {
const dt = new Date();
dt.setDate(dt.getDate() - index);
const year = dt.getFullYear();
const month = dt.getMonth() + 1;
const day = dt.getDate();
const id = year + "-" + month + "-" + day;
return {
id,
year,
month,
day,
dayOfWeek: dt.getDay(),
dayDiffFromToday: index,
activities: dataObj[id] || [],
};
});
};
const getDayRight = ({ dayDiffFromToday = 0, dayOfWeek = 0 }) => {
const todayDayOfWeek = new Date().getDay();
return (
(Math.floor(dayDiffFromToday / 7) + (dayOfWeek > todayDayOfWeek ? 1 : 0)) *
14
);
};
export default {
name: "activity-calendar",
components: { Day: Day },
props: ["data", "weekStart", "daysToRender", "handleDayClick"],
methods: { getDays, getDayRight },
};
</script>
… And React, Svelte, Solid and Qwik. Not bad, huh? Let’s try passing a onClick
handler called handleDayClick
.
<ActivityCalendar
:daysToRender="150"
:data="data"
:handleDayClick="handleDayClick"
/>
<script>
export default {
name: 'App',
data() {
return {
data: [ ... ]
};
},
methods: {
handleDayClick: (dt) => {
console.log(dt);
},
},
};
</script>
Click the day box and see the result!
9. Customize the color theme
Lastly, let’s customize the box color by CSS variables instead of inline passing the color
props. Indeed, theming by CSS variables is only one of the ways, and every framework has its own native way of doing theming. Mitosis might not be able to support the specific native theming, but it is good to know that Mitosis can them by CSS variables.
In another React.js demo, let’s try to add the following line to src/style.css
.
:root {
--day-box-color: teal;
}
And here’s what the teal-ish Activity Widget looks like!
What I Learned
You might need to code in a “Mitosis” way
Mitosis is great and works fine most of the time, but it does come with limitations that restrict us from writing in a more declarative “Mitosis” way. Sometimes, I code something in a common React way, e.g., computing the default state value with props and finding out that it is not allowed. In addition, their ES Lint plugin is a great “reminder” of these constraints. Altogether, it should catch most of the errors.
Try extracting the logic into framework-agnostic pure functions
The restrictions are significantly lower if the logic isn’t placed inside a Mitosis functional component and is (mostly) pure. For instance, I’m allowed to destructure objects (Mitosis seems to hate destructuring 🫠 jk jk) and have comments inside, like how I normally did in React.js. Also, when I inspect the generated components of React.js or other frameworks, those functions are almost a 1-to-1 match.
Use ‘css’ for static styling and ‘style’ for dynamic styling
While css
is the suggested way of styling, it seems impossible to use variables there. There is another way - the style
props - of styling an element in Mitosis. If the styling is static (won’t be changed corresponding to variables), use css
which will generate a CSS class or styled component in the target framework components.
Use style
if you need to determine the styling value by variable, e.g., the opacity of the <Day />
box 🟩, which will generate an inline style attribute to the HTML element.
You can also place a CSS class
to the component and provide CSS file(s) for the users to import
. In addition, you can useStyle
to style the component, which I haven’t played around with this option yet.
Avoid naming the function props as event listeners like on”Event,” e.g., onClick
When naming the function props as event listeners like on"Event"
, e.g., onClick
and generating components for Svelte, it will be transformed to on:click
. How about naming it as non-standard events like onDayClick? Currently, it is also generated to on:dayClick
. To avoid this issue, try something like handleDayClick
at this moment.
Mitosis doesn’t come with a dev server yet
While you can generate framework components by npm exec mitosis build
, Mitosis doesn’t come with a dev server to preview them. I end up setting up a dev server with Astro. Not optimal, but usable enough. I will share the setup in the next article.
Epilogue
Overall, the experience of using Mitosis is similar to React.js, with a few hiccups. For example, I must constantly check whether the generated components break in certain frameworks. However, once I got used to the limitations, I would say Mitosis is a great tool to work with, especially when you want to build slightly more complex components than just a themed button or nav bar, which targets multiple frameworks.
I noticed that the communication between different frameworks and Web Components is improving nowadays, except React.js. Building a POC with Web Components is definitely on my radar. Follow and connect with me for more interesting projects!