Extending React
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 came before 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 router:
const router = (
<Dir name="site">
<File name="index.html">
<Posts>{posts}</Posts>
</File>
<Dir name="posts">
{posts.map((post) => (
<File name={`${post.slug}.html`} key={post.slug}>
<Post {...post} />
</File>
))}
</Dir>
</Dir>
);
router.write(dirPath)
writes the site to disk, mounting the files
site
├── index.html
└── posts
└── extending-react.html
in the directory dirPath
.
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" />;
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 by design -- it enables composition.
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 />
<Body>{children}</Body>
</html>
);
// Usage example:
const About = () => (
<Page>
<div className="about">
<h1>About</h1>
<p>
Hello, my name is Bruno Fernandes, and I write code for fun and profit.
</p>
</div>
</Page>
);
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 now have a transpiler directive to accommodate frameworks other than React.
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:
function createElement(type, props, ...children) {}
-
type
is the JSX element type.It is a string for elements with lowercase names, and a function or class reference otherwise.2
-
props
is an object keyed by props passed to the element. -
children
represents the rest of the parameters, which are 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 fs from "fs";
import path from "path";
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 file of this.content) {
file.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 simply overwrite the content.
Hijacking the JSX transform
We tell the build tool to transform JSX tags using our own factories:
- JSX elements should call
JSX.createElement
, and - JSX fragments should expand to reference
JSX.Fragment
.4
In Babel, this looks like
// babel.config.js
module.exports = {
presets: [
[
"@babel/preset-react",
{
pragma: "JSX.createElement",
pragmaFrag: "JSX.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 JSX = {
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 content`;
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` is a string
}
That is as much as we need to use the library successfully!
Cleaning up
There are a couple of improvements that we can make:
createElement
should prevent children from being passed asprops.children
.FileSystem#write
implementations could 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
The simplest way to make the change is to use async
-await
syntax.
File#write
only differs from its synchronous form by two keywords:
import fs from "fs/promises";
class File extends FileSystem {
async write(rootPath) {
const filePath = path.join(rootPath, this.name);
try {
await fs.writeFile(filePath, this.content);
} catch (_) {
throw new FileWriteError(filePath);
}
}
}
Dir#write
uses the Promise.all
combinator:
class Dir extends FileSystem {
async write(rootPath) {
const dirPath = path.join(rootPath, this.name);
try {
// Write the parent directory first,
await fs.mkdir(dirPath, { recursive: true });
} catch (_) {
throw new FileWriteError(dirPath);
}
// and then all the children at once
await Promise.all(this.content.map((file) => file.write(dirPath)));
}
}
Footnotes
-
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. ↩
-
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. ↩
-
Since the release of React 17, many transpilers can "automatically import" React. ↩
-
Fragment syntax allows components to return multiple elements without boilerplate code. ↩
-
This bad practice can be identified during linting; by default, the ESLint React plugin stops children from being passed directly through props. ↩
-
Building a website from a script only taxes a machine briefly anyway, so the difference in runtime shouldn't be noticeable. ↩