Extending React

12 May 2021 · # Compilers # JSX · 963 words

Facebook's JavaScript user interface library ReactJS has taken the world of frontend development by storm since its public introduction at JSConf 2013.

Unlike the Model-View frameworks that preceded it, React drives UI updates by declarative state management. Controversially, React introduced an XML-like syntax extension to JavaScript called JSX so developers could combine markup syntax and render logic in application code.

The idioms React promoted were not initially well-received, but they have gone on to become popular.

We will extend React's JSX API to create a small NodeJS library that can be used in scripts to build a static website through the declarative composition of File and Dir primitives.

For example, part of this blog can be described by the following static router:

const router = (
  <Dir name="site">
    <File name="index.html">
      <PostList posts={posts} />
    <Dir name="posts">
      {posts.map((post) => (
        <File name={`${post.slug}.html`} key={post.slug}>
          <Post {...post} />

router.write(__dirname) writes the site to disk, mounting the files

  ├── index.html
  └── posts
      └── extending-react.html

in the directory where write was invoked.

In this way, anyone can roll their own React-based static site generator like Gatsby.

Understanding React

A minimal understanding of how React works is required to extend its API. Let's review some concepts.

Components and Elements

React applications are comprised of template entities called components.

They can be declared with a class or with a function.1 For example, the following two ways of declaring a Welcome component that greets a user are equivalent:

// Class component
class Welcome extends React.Component {
  render() {
    return <div>Hello, {this.props.name}</div>;

// Function component
const Welcome = ({ name }) => <div>Hello, {name}</div>;

Welcome accepts a name property or "prop" as input, which tells it who it should greet.

Elements can be regarded as instances of components; they can be rendered to the DOM in a browser:

const node = <Welcome name="Scully" />; // Instance of `Welcome` created with JSX
ReactDOM.render(node, document.getElementById("root"));

, or as a string on a server:

renderToStaticMarkup(<Welcome name="Mulder" />);

Note that function components and the render method of class components can themselves return elements -- this is not a coincidence!

Composition and Children

React promotes code encapsulation and reuse through composition rather than inheritance.

The children prop of a component has access to the children passed to it; this can be used to render generic components that do not know their children ahead of time. For example, a Page component can be used to add a common header to every page of a website:

const Page = ({ children }) => (
  <html lang="en">
    <Head />

// Usage example:
const About = () => (
    <div className="about">
        Hello, my name is Bruno Fernandes, and I write code for fun and profit.

Desugaring JSX

Browsers and Node cannot understand JSX, so it must be transpiled to plain JavaScript before use.

During transpilation, build tools such as Babel and esbuild transform any JSX tags they encounter to calls to a JSX factory function. Historically, the factory was React.createElement, but build tools made the factory configurable using a pragma or transpiler directive to accommodate more frameworks that support JSX.

For example, the script

import React from "react";

const Welcome = ({ name }) => <div>Hello, {name}</div>;

renderToStaticMarkup(<Welcome name="Mulder" />);

could be transformed into

import React from "react";

const Welcome = ({ name }) => React.createElement("div", null, "Hello, ", name);

renderToStaticMarkup(React.createElement(Welcome, { name: "Mulder" }));

by the first stage of a preprocessor or transpiler for React.

From looking at how React.createElement is invoked, we can learn about the expected signature for the factory function:

  • First parameter: JSX element type. A string for lowercase types, a reference for uppercase ones.2
  • Second parameter: An object keyed by props passed to the element.
  • The rest of the parameters are populated with transformed child elements.

Also, note that the React object must be in scope wherever JSX is used.3

Writing the library

We can exploit a user-customisable JSX transform and what we've seen of React's composition model to implement our API. The idea is simple: calls to File or Dir "components" should be intercepted and recast as instantiations of filesystem objects. These objects must know how to write their contents to disk recursively.

The filesystem abstraction

Observe that we can model files and directories on disk as trees where files form the terminal nodes. Any file or directory should be able to write its contents to disk when given a root path.

FileSystem forms the notion of this interface for files and directories:

class NotImplementedError extends Error {}

class FileSystem {
  constructor(name, content) {
    this.name = name;
    this.content = content;

  write(rootPath) {
    throw new NotImplementedError(); // Runtime interface emulation

File and Dir implement the write behaviour:

import path from "path";
import fs from "fs";

class FileWriteError extends Error {
  constructor(filePath, ...args) {
    const msg = `Could not write to ${filePath}`;
    super(msg, ...args);

class File extends FileSystem {
  write(rootPath) {
    const filePath = path.join(rootPath, this.name);
    try {
      fs.writeFileSync(filePath, this.content);
    } catch (_) {
      throw new FileWriteError(filePath);

class Dir extends FileSystem {
  write(rootPath) {
    const dirPath = path.join(rootPath, this.name);
    try {
      // Create the directory if it does not exist
      fs.mkdirSync(dirPath, { recursive: true });
    } catch (_) {
      throw new FileWriteError(dirPath);
    for (const fileOrDir of this.content) {
      fileOrDir.write(dirPath); // recurse

If a call to write fails for whatever reason, a partial write will occur. This is acceptable -- subsequent runs of the static site generator will overwrite content and remediate the problem.

Hijacking the JSX transform

We tell the build tool to transform JSX tags using our own factories:

  1. JSX elements should call Template.createElement, and
  2. JSX fragments should expand to reference Template.Fragment.4

In Babel, this looks like

// babel.config.js
module.exports = {
  presets: [
        pragma: "Template.createElement",
        pragmaFrag: "Template.Fragment",

Then we export some stub File and Dir components for the user to reference:

export const File = () => <></>; // no-op
export const Dir = () => <></>; // no-op

Our JSX transformer createElement should intercept any File or Dir stub references and delegate all other calls to React:

const Template = {
  createElement(type, props, ...children) {
    if (type === Dir) {
      return createDir(props, children);
    if (type === File) {
      return createFile(props, children);
    return React.createElement(type, props, ...children);
  Fragment: React.Fragment, // An alias for developer convenience

createDir verifies that all children are FileSystem instances before creating a Dir:

class RoutingError extends Error {}

function createDir(props, children) {
  // Trivial input verification omitted
  const { name } = props;
  children = children.flat();

  if (!children.every((c) => c instanceof FileSystem)) {
    const msg = `Children of directory ${name} must be directory or file elements`;
    throw new RoutingError(msg);
  return new Dir(name, children);

createFile verifies that there is only one child before creating a File:

function createFile(props, children) {
  // Trivial input verification omitted
  const { name } = props;

  if (children.length !== 1) {
    const msg = `File ${name} must have a single child element or string`;
    throw new RoutingError(msg);

  const [content] = children;

  if (React.isValidElement(content)) {
    const page = `<!DOCTYPE html>${renderToStaticMarkup(content)}`;
    return new File(name, page);
  return new File(name, content); // `content` must be a string

That is as much as we need to use the library successfully!

However, there are a couple of improvements that we can make:

  1. createElement should prevent children from being passed as props.children.
  2. FileSystem.write implementations should use the promise-based Node filesystem API.

Passing children directly through props is discouraged.5 It leads to quirks when children are also passed through composition, as the transpiler discards props.children when calling the JSX factory.

We can add the following block to createDir and createFile to prevent developers from passing children directly to Dir or File:

if (props.children) {
  const msg = `Contents of ${name} must be passed as nested children`;
  throw new RoutingError(msg); // Why would anyone do this?

Code like

<File name="about.html" children={<About />} /> // Not OK

will throw when executed.

Using callback or promise-based Node APIs to write directories and files asynchronously means we can ask the operating system to try writing all the children of a directory at once. Currently, we wait for one child to be written before we start writing the next one.6

  1. Before the introduction of React Hooks in React 16.8, class and function components served different purposes. Now that function components can also manipulate React state through hooks, class components are somewhat redundant.
  2. In practice, this means that user-defined React components must be named with a capital letter or be assigned to a capitalised variable before use.
  3. Since the release of React 17, many transpilers can "automatically import" React.
  4. Fragments allow components to return multiple elements without adding extra nodes to the DOM.
  5. This bad practice can be identified during linting; by default, the ESLint React plugin stops children from being passed directly through props.
  6. Building a website from a script only taxes a machine briefly anyway, so the difference in runtime shouldn't be noticeable.