Preact's best kept secret
📖 tl;dr: This post is about "option hooks", a powerful feature that is used behind the scenes in `preact/compat` and `preact/hooks`. As a user you don't need to know about these to use Preact. This post is rather a deep dive into the internals of Preact
I though I'd start the blog with something special that not many Preact users may know about. It's one of the least known features and also one of the most powerful ways to extend Preact: Option-Hooks. They allow anyone to plug into our reconciler and extend Preact without needing to make any modifications in our core. This power is what enables us to ship various addons like preact/compat
and preact/hooks
to name a few. They've been in Preact since the very early days reaching back to 2015 ✅.
⚠ Note: Internally they've always been referred to as hooks in our code base. They are not to be confused with the recent hooks feature that was introduced by the react team.
At the time of this writing we expose 9 hooks for the various reconciliation stages:
options.vnode
: Allows to mutate avnode
just before passing it to the reconcileroptions.commit
: Called when a wholevnode
-tree is mounted or updatedoptions.unmount
: Called when avnode
will be removed from the DOM.options.diff
: Called right before avnode
is compared against the previous oneoptions.render
: Called right before rendering a componentoptions.diffed
: Called after the comparison between 2vnodes
finishedoptions.event
: Called when an event handler will be dispatchedoptions.requestAnimationFrame
: Called when a layout effect will be scheduledoptions.debounceRendering
: Called when an update is scheduled
Note: vnode
(= virtual DOM node) is the data structure at the heart of Preact and is used thorough the diff process to compare elements to each other.
You may rightfully ask why we didn't choose more descriptive name for these. The reason behind is our strong focus on bundle size. By re-using words that can already be found elsewhere in our code we can play in the hands of the gzip compression algorithm ✌️. It's optimized to optimally compress repeating patterns and we have quite a few of them.
Extending Preact via addons
A great example of where they're used is in the recently added preact/hooks
addon. Whenever a hook is invoked it will be attached to the current component so that it can be called again once the component rerenders. But because hook functions don't receive any reference to the component through any function arguments, we need to keep track of the current component through other means. For this we can leverage options.render
to just store the reference in a variable and options.diffed
after it's done rendering to flush any pending effects.
import { options } from "preact";
// Reference to the component that's currently rendering
let currentComponent;
// Index into array of hooks on a component instance
let currentIndex;
// Keep track of the current component and index of the hook. Each hook will
// increment the index automatically.
options.render = vnode => {
currentComponent = vnode._component;
currentIndex = 0;
};
// Schedule any pending effects after the component is done rendering
options.diffed = vnode => {
const hooks = vnode._component._hooks;
hooks._pendingEffects.forEach(effect => invokeEffect(effect));
};
preact/compat
employs a similar trick: To make third-party libraries belief that we are React we need to patch our internal vnode
structure with React-specific properties. Many 3rd-party libraries explicitely check for them and will bail out if they are missing. So we need to apply normalizations as early as possible before any 3rd-party code is run. In our case we chose to do this right when a vnode
is created in createElement
(or h
). Internally it relies on options.vnode
to do the magic:
// Pseudo implementation for jsx constructor
function createElement(type, props, ...children) {
const vnode = createVNode(type, props, children);
if (options.vnode) options.vnode(vnode);
return vnode;
}
// In `preact/compat`:
options.vnode = vnode => {
// Add react-specifc $$typeof property
vnode.$$typeof = REACT_ELEMENT_TYPE;
};
Besides allowing addons for greater ecosystem compatibiliy the options hooks open up a whole world of experiments and prototypes. We have a plethora of them spread out over gists, codesandboxes and fiddles that prototype various ideas. These quick prototypes are a lot easier to share and allow us to evaluate if it's any good before spending the time integrating it natively into core.
One of our users Rasmus Schultz for example shared a different form of components an alternative to hooks.
Hooks re-imagined
They're largerly inspired by the way components are handled in ivi which leverages closures to circumvent the need to keep track of the current component. Previously we established that we only need to do that because a hook doesn't receive the component reference in any function argument. This way of declaring components does exactly that:
const Counter = component(instance => {
let value = 0;
function count() {
value += 1;
// `invalidate` is conceptually the same as `setState`
invalidate(instance);
}
useEffect(instance, () => console.log(`You clicked ${value} times`));
// Return a function (=render method)
return props => (
<button onClick={count}>
{props.title}: {value}
</button>
);
});
Just by wrapping our component implementation with a function that initializes it. Instead of returning the virtual DOM directly, it returns another function as the render method. The outer function is used to initialize all hooks or event handlers and basically acts as a constructor for the render method. This has the benefit of not having to re-create functions on each render
.
The downside is that props
can only be accessed via a reference to the instance. They are not value
bound anymore which can lead to tearing when mixed with asynchronicity. Nonetheless this is a great alternative to hooks which just chooses a different set of tradeoffs. And we always love and encourage such experiments. Before you know it these may turn out to be something really cool ⚡.
Remember even Preact X just started out as a random experiment and before we knew it, turned into the next major version for Preact. For that reason our option hooks our little secret allowing us to quickly iterate and prototype new ideas 🙌.