Behind the block()
If you've used Million.js for a while, you've probably heard of the block()
function.
function MyComponent() {
// ...
}
const MyBlock = block(MyComponent);
export default function App() {
return <MyBlock />; // ✨ it works! ✨
}
Wrapping a React component with block()
creates a block. A block is a special Higher Order Component (HOC) (opens in a new tab) that can be used as a React component but are hyper-optimized for rendering speed by rendering using Million.js.
But how can this be? How can we use blocks inside of React? Isn't Million.js a completely different virtual DOM?
Anatomy of block()
Once you've created a block and use it as a React component, the following will occur during rendering:
React renders <Loader />
component
Initially, React is responsible for rendering the <Loader />
component. This process involves creating the necessary DOM elements and applying any initial properties or styles. During this phase, React is managing the lifecycle and state of the component, allowing for rich features such as state management, lifecycle methods, and more.
React mounts <Loader />
and puts the DOM element in the ref
Following the rendering process, React then mounts the <Loader />
component. This involves inserting the component into the DOM and making it visible to the user. At this point, React also updates the ref with the DOM element. A ref in React is a way to hold local state that doesn't invoke rendering, and in this case, it's being used to store a reference to the DOM element.
Million.js renders <App />
into the ref
Finally, the ref is handed over to Million.js, a fast, lightweight virtual DOM. Using the DOM reference stored in the ref, Million.js renders the <App />
component directly into this element. This allows Million.js to manage the <App />
component separately from React, leading to potential performance benefits and isolation of responsibilities.
This pattern allows us to "control" the DOM element without React knowing about it. React will only know about the <Loader />
component, and Million.js will only know about the <App />
component.
Implementing block()
With this in mind, we can create a basic implementation of this pattern.
Note: This is not the actual implemention, rather more a conceptual code sample. View the source here. (opens in a new tab)
Creating a HOC factory
An HOC factory consumes some React component and spits out our <Loader />
component. The <Loader />
component is responsible for rendering the DOM element and passing it to Million.js.
const block = (ReactComponent) => {
return function Loader(props) {
return /*... */;
};
};
Grabbing the DOM element with useRef()
We can use the useRef()
hook to grab the DOM element.
const block = (ReactComponent) => {
return function Loader(props) {
const el = useRef(); // stores the DOM element
return <div ref={el}></div>;
};
};
Create an effect to render Million.js
Now, we put it all together. We create a <Effect />
component that runs an effect on mount. This effect is responsible for rendering the <App />
component into the DOM element. We use useCallback()
to create a stable closure reference to the effect.
Notice how there are Million.convert()
and Million.render()
calls. These are not real, but they essentially create blocks and render it into the DOM element.
const block = (ReactComponent) => {
const MillionComponent = Million.convert(ReactComponent);
return function Loader(props) {
const el = useRef();
// 3. Million.js renders <App /> into the ref
const effect = useCallback(() => {
// useCallback is used as a stable closure reference
Million.render(MillionComponent, el.current);
}, []);
// 2. React mounts <Loader /> and puts the DOM element in the ref
return (
<>
<div ref={el}></div>
<Effect effect={effect} />
</>
);
};
};
// Effect is a component that runs an effect on mount
function Effect({ effect }) {
useEffect(effect, []);
return null;
}
Compiler, you're a wizard! 🧙
One major limitation of the runtime implementation is that it requires the user to pass in a stateless component. This is because the internal block implementation has a number of limitations. However, we can use the compiler to get around this limitation.
Let's say we have an <Emotion />
component that has some isSad
state, and based on that state, it renders a 😢 or 😂 emoji.
function Emotion() {
const [isSad, setIsSad] = useState(true);
return <div>{isSad ? '😢' : '😂'}</div>;
}
const EmotionBlock = block(Emotion);
The compiler can extract out the isSad
state and convert it into a prop that Million.js can understand.
function Emotion_jsx({ _0 }) {
return <div>{_0}</div>;
}
const Emotion_jsx_block = block(Emotion_component);
function EmotionBlock() {
const [isSad, setIsSad] = useState(true);
return <Emotion_jsx_block _0={isSad ? '😢' : '😂'} />;
}
But what if we had another React component inside of <Emotion />
?
function SadEmoji() {
return '😢';
}
function HappyEmoji() {
return '😂';
}
function Emotion() {
const [isSad, setIsSad] = useState(true);
return <div>{isSad ? <SadEmoji /> : <HappyEmoji />}</div>;
}
const EmotionBlock = block(Emotion);
Similarly, this is extracted, but during rendering when it meets a component boundary, it will create a "React render scope." Essentially, it delegates the responsibility of rendering the component to React.
function SadEmoji() {
return '😢';
}
function HappyEmoji() {
return '😂';
}
function Emotion_jsx({ _0 }) {
return <div>{_0}</div>;
}
const Emotion_jsx_block = block(Emotion_component);
function EmotionBlock() {
const [isSad, setIsSad] = useState(true);
return (
<Emotion_jsx_block
_0={renderReactScope(isSad ? <SadEmoji /> : <HappyEmoji />)}
/>
);
}
As you can see, the compiler is able to extract out the state and render it from a parent element. It can also recognize when it hits a component boundary and delegate the responsibility of rendering to React.
Not just Million.js
While this article details how Million.js takes advantage of this pattern, it is not limited to just Million.js.
For any modern framework that can render into a DOM element, you can use the <Loader />
and HOC pattern to render foreign framework components inside of React.
A very similar concept is the "islands architecture" (opens in a new tab), which allows you to incapsulate any framework into static HTML. This is a bit different, instead of rendering into static HTML, it renders into a React tree.
Why not a compatibility layer?
JavaScript frameworks like Preact (opens in a new tab) and Inferno (opens in a new tab) have compatibility layers that allow them to masquerade as React components but with better performance. This has a lot of benefit, as it allows projects and engineering teams to move very fast without having to rewrite their entire codebase.
But it comes at a cost. Compatibility layers always have to play catch up. When React adds a new feature, the compatibility layer has to add support for it. Maintaining the same behavior is near impossible, especially emulating the same behavior and benefits of the React concurrency model.
Closing Thoughts
By using specific different rendering methodologies on a component-by-component basis, we can take advantage of the best of both worlds and use the right tool for the right job. Hopefully one day, we'll see more frameworks adopt this pattern. Because performance shouldn't be a tradeoff for migration.
Discuss on Twitter (opens in a new tab) | Edit on GitHub (opens in a new tab)
Acknowledgements
Thank you to Ryan Carniato (opens in a new tab) for creating an initial Solid.js inside React proof-of-concept (opens in a new tab) that inspired this article.
Looking for more? Check out another interesting read (opens in a new tab) from Yongjun Park (opens in a new tab).