Lazy Loading React Components via Websocket


This is an experimental concept and not recommended for production use.

So, I’ve been tinkering with Scratch GUI lately, and holy moly – their bundle size is a whopping 30MB (15MB gzipped)! Like any sane developer, I thought, “Time for some lazy loading magic.” But as I dug into React’s lazy API, I realized there’s more to it than meets the eye.

Lazy Loading: The Basics (with a Twist)

You probably know the drill: lazy lets you split your bundle and load components on-demand. But let’s take a closer look at how it actually works:

import { lazy } from "react";

const MarkdownPreview = lazy(() => import("./MarkdownPreview.js"));

As you can see, lazy takes a function that returns a promise, and the promise should resolve to a module with a default export containing a React component. But if we read it carefully, we’ll notice that lazy accepts a function that returns a thenable function (aka a promise), and the promise should resolve to a plain object with a default key that contains a React component, like this:

{
  default: () => React.ReactNode
}

That’s the module we’re talking about – just an object, right? We can create it ourselves.

So, what’s the point? Can we make the function argument like this? Is it valid?

import { lazy } from "react";
import TheRealMarkDownPreview from "./MarkdownPreview.js";

const MarkdownPreview = lazy(async () => {
  // just return the module, can we?
  return TheRealMarkDownPreview;
});
import { lazy } from "react";

const MarkdownPreview = lazy(async () => {
  await doSomething();
  await doSomethingElse();
  await fetchApi();
  const theRealModule = await import("./MarkdownPreview.js");

  return theRealModule;
});

Absolutely, we can. The point here is that we can do whatever we want inside the function, as long as it returns a thenable function that resolves to a module with a default key that contains a React component. Even if we didn’t use import('./MarkdownPreview.js') directly, it’s still fine.

Loading React Components over Websocket?

The blog title is just bluffing, but it’s not that far-fetched. I believe you have some understanding after reading the previous section. You might’ve noticed that you can do some tasks before returning the object module, but have you considered this scenario?

import { lazy } from "react";

// load my component via http
const MarkdownPreview = lazy(async () => {
  const res = await fetch("/api/load-component?name=MarkdownPreview");
  const componentData = await res.json(); // { jsUrl: 'https://another-website.com/dist/MarkdownPreview.js' }
  // just load the script by appending a script tag
  await runScript(componentData.jsUrl);
  // the component is now available in the global scope,
  // it injects to the window object as `MarkdownPreview` variable
  // that's the logic, but not limited to this
  const theModuleLoadedViaHttp = window.MarkdownPreview; // { default: ReactComponent }

  // should be ok
  return theModuleLoadedViaHttp;
});

But why? Why do we need to do that? The goal is to free your mind from the documentation and encourage thinking outside the box. With a fundamentally different understanding of the API, we can do more things.

If you’re still curious about use cases, here are a few examples:

Here’s an implementation for loading a React component through a websocket, and I’ve also prepared a websocket server. You can give it a try on the playground. I think you’ll get the idea. Server code

import {createElement, lazy, useEffect, useState} from 'react' const TodoRemote = lazy(async() => { return new Promise(resolve => { const socket = new WebSocket('wss://websocket-component.graphvn.workers.dev/ws') socket.addEventListener("open", (event) => { socket.send('hello') }) socket.addEventListener('message', async(event) => { const {todos, componentUrl} = JSON.parse(event.data) console.log(todos) // workaround for dynamic import const {TodoList} = await new Function(`return import('${componentUrl}')`)() // the component resolve(Promise.resolve({ default: () => todos.map(todo => { return createElement(TodoList, { todo, key: todo.id, }) }) })) }) }) }) export default function App() { return ( <div style={{height: '200px'}}> <TodoRemote /> </div> ) }