One of the features in Stym is a profile management widget that can be embedded on any website. The widget is connected to our backend and lets users manage their profile, billing, and invoices. When I started to build it, I didn't want to code in vanilla JS. I wanted to use React but as something that will be embedded in other websites, the main requirement was a small size, preferably under 50kb. So I picked Preact.
Why Preact?
Preact has almost the same API but it's very lightweight. How light? It's only 3kb!! 🤯 Granted, its diffing algorithm is quite simple but for my use cases, it was perfect. I also didn't need all of React's features. I just wanted composable components that are easy to write. I've never used Preact before but was excited to check it out.
Since Preact has such a small size, browsers can parse the entire code fast which results in a good user experience. And because of the API, all React modules are available for use so what we can build with it is limitless.
Gotchas
There were a few things to look out for when building this.
1. h()
and render()
The h()
and render()
functions are the most commonly used in a Preact project. h()
is what turns JSX into Javascript.
Unlike React, we don't need a separate DOM package in Preact. That is the reason why Preact is so small. The minified react
package is only 11kb. But the majority of what React does is in the react-dom
package which is 120kb, minified.
Preact has a simpler render()
which adds the UI to the DOM.
2. Using NPM modules made for React
Preact also comes with a compatibility layer for React available under preact/compat
. To use React modules with Preact, we have to alias react
and react-dom
to preact/compat
. This is how we do it in Webpack:
module.exports = { ..., resolve: { alias: { react: 'preact/compat', 'react-dom': 'preact/compat', }, } };
3. Routing
The widget I was building had multiple pages. So I needed some form of routing. Preact does have a separate preact-router
package but it would affect the routing of the parent website. And I didn't want the extra weight. So I used an in-memory router that would handle page changes within the widget without affecting the parent website.
Here's the code:
import { createContext, createElement, h } from 'preact' import { useEffect, useState } from 'preact/hooks' const DEFAULT_ROUTE = '/' export const RouterContext = createContext({ route: DEFAULT_ROUTE, setRoute: () => undefined, }) export const Router = ({ routes, onChange }) => { const [route, setRoute] = useState(DEFAULT_ROUTE) useEffect(() => { onChange?.(route) }, [route]) return ( <RouterContext.Provider value={{ route, setRoute }}> {routes[route]} </RouterContext.Provider> ) } export const RouteComponent = (props) => { const { component, ...rest } = props return createElement(component, rest) } /** * Render anchor with click handler to switch route based on `href` attribute. * We intentionally override final `href`, so links within widget won't lead to actual pages on the parent website. */ export const Link = ({ href, children, ...rest }) => ( <RouterContext.Consumer> {({ setRoute }) => ( <a href='javascript:' onClick={() => href && setRoute(href)} {...rest} > {children} {' '} </a> )} {' '} </RouterContext.Consumer> )
Now I can use this Router component in the root component. There's also an onChange
prop that I can pass a function to, for handling route changes. I'm using this to show a back button inside the widget when it's not the main page.
<Router onChange={handleRouteChange} routes={{ '/': <RouteComponent component={Main} />, '/edit': <RouteComponent component={Edit} />, }} />
Passing Props
Users can add this widget using a script tag, but I also wanted to pass some initial props to it for use in backend calls. Initially, I used preact-habitat
to render the widget components to DOM. But in the end, I removed it because passing props to the widget from the script tag on the parent weren't the way I liked it.
Instead I did this:
import { h, render } from 'preact' import Widget from './components/Widget' import './styles/global.css' export function init(id, wrapper) { const renderElement = document.querySelector(wrapper) ? document.querySelector(wrapper) : document.querySelector('body') render(h(Widget, { id }), renderElement) }
Now I can use props.id
on my widget component. I also wanted the init()
to be available under Widget
and add that to the window
object. This is quite simple to do in webpack:
module.exports = { ..., library: 'Widget', libraryTarget: 'umd' ... };
To call the init()
function from the script tag, I simply added an onload
attribute that would call window.Widget.init('id')
.
The script tag that adds this widget to any website will look like this:
<script> !function(){const n=document.createElement("script");n.type="text/javascript";n.async=!0;n.src="https://path.to/widget/script.js";n.onload=function(){ Widget.init('Z2GuRgCSrfj'); };const a=document.getElementsByTagName("script")[0];a.parentNode.insertBefore(n,a); }(); </script>
Apart from the above things I did to get the widget working, everything else was like building a typical React web application. There were some more dependencies I added that made building it easier, like Tailwind and React Feather (for icons). To make calls to the backend, I installed redaxios
which is a much lighter version of axios
. I could have used fetch
but it didn't fit my use case.
Along with the dependencies, tree shaking by webpack, and purging unused CSS by tailwind, the final package was ~34kb, which was fantastic! 😁
There was some initial setup and a lot of googling but in the end, I was very happy with how the widget turned out. Now that I've used it, I see so many possibilities for using Preact, especially where there is a strict size constraint. This means Preact apps could be used as embeds, browser extensions, for white-labeling a product, and deployments in places with poor internet connections!
I hope this article was helpful. Have you used Preact or actively use it in your projects? What do you use it for? How was your experience using it? Let me know in the comments.
Until next time.