Extending React
Facebook's JavaScript UI library React took the world of frontend development by storm after its public introduction at JSConf 2013.
Unlike Model-View frameworks, React drives UI updates by declarative state management. Controversially, React introduced an XML-like syntax extension to JavaScript called JSX. It allows programmers to combine markup and render logic in application code.
The ideas React promotes were not initially well-received but quickly gained wide acceptance.
We will extend React's JSX API to create a small NodeJS library for static site generation. The library will expose File
and Dir
JSX primitives that can be composed to define a website.
For example, the following router describes part of this blog:
const router = (
<Dir name="site">
<File name="index.html">
<Posts>{posts}</Posts>
</File>
<Dir name="posts">
{posts.map(({ slug, ...post }) => (
<File name={`${slug}.html`}>
<Post {...post} />
</File>
))}
</Dir>
</Dir>
);
The command router.write(dirPath)
writes the site to disk, mounting the files
site
├── index.html
└── posts
└── extending-react.html
in the directory dirPath
.
Anyone can use this library to roll their own React-based static site generator (SSG).
React 101
A minimal understanding of how React works is required to extend its API. Let's review some concepts.
Components and Elements
React projects consist of template entities called components. They can be declared with a class or with a function.1
For example,
class PaginationLink extends React.Component {
render() {
return <a href={`/posts/${this.props.slug}.html`}>Next</a>;
}
}
and
const PaginationLink = ({ slug }) => <a href={`/posts/${slug}.html`}>Next</a>;
are equivalent ways of declaring a PaginationLink
component. PaginationLink
accepts a slug
property or "prop" as input. slug
tells a PaginationLink
element where it should point.
You can regard an element as an instance of a component. Elements can be rendered to the DOM by a browser:
const node = <App />;
render(node, document.getElementById("root"));
Elements can also be serialized into a string by a server:
renderToStaticMarkup(<App />);
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 reuse through composition.2
A component's children
prop provides access to the children passed to it. children
can be used to define generic components that find out their children at runtime.
For example,
const Page = ({ children }) => (
<html lang="en">
<Head />
<Body>{children}</Body>
</html>
);
adds an identical header to every child.
I use Page
to assemble the colophon of this blog:
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 NodeJS cannot understand JSX, so it must be transpiled to plain JavaScript before use.
During transpilation, transpilers such as Babel and esbuild transform any JSX expressions they encounter into calls of a JSX factory function. Historically, the factory was React.createElement
. Now, transpilers support a variety of UI frameworks through transpiler directives.
For example, the script
import React from "react";
const PaginationLink = ({ slug }) => <a href={`/posts/${slug}.html`}>Next</a>;
renderToStaticMarkup(<PaginationLink slug="extending-react" />);
could be transformed into
import React from "react";
const PaginationLink = ({ slug }) =>
React.createElement("a", { href: `/posts/${slug}.html` }, "Next");
renderToStaticMarkup(
React.createElement(PaginationLink, { slug: "extending-react" }),
);
by the first stage of a preprocessor or transpiler for React.
Transpilers stipulate that the JSX factory must take the form
(name: Name, attributes: Attributes, ...children: Element[]) => Element;
, where
-
name
is a JSX element name or a JavaScript identifier, -
attributes
is an object keyed by attributes, and
type Attributes = { [key: string]: any } | null;
children
represents transformed child elements.3
Also, note that the React
object must be in scope wherever you use JSX with React.4
Putting pen to paper
We can exploit a user-customizable JSX Transform and composition 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 files and directories form trees in which files are the terminal nodes. Any file or directory should be able to write its contents to disk when given a root path.
We can encode this contract in an interface FileSystem
:
interface FileSystem {
name: string;
content: string | FileSystem[];
write(rootPath: string): Promise<void>;
}
And File
and Dir
can implement FileSystem
:
import fs from "fs/promises";
import path from "path";
class File implements FileSystem {
public name: string;
public content: string;
constructor(name: string, content: string) {
this.name = name;
this.content = content;
}
async write(rootPath: string) {
const filePath = path.join(rootPath, this.name);
try {
await fs.writeFile(filePath, this.content);
} catch (_) {
throw new Error(filePath);
}
}
}
class Dir implements FileSystem {
public name: string;
public content: FileSystem[];
constructor(name: string, content: FileSystem[] = []) {
this.name = name;
this.content = content;
}
async write(rootPath: string) {
const dirPath = path.join(rootPath, this.name);
try {
await fs.mkdir(dirPath, { recursive: true });
} catch (_) {
throw new Error(dirPath);
}
await Promise.all(this.content.map((file) => file.write(dirPath)));
}
}
A partial write will occur if a call to write
fails. This is acceptable. The SSG overwrites content between runs, so a failure followed by a retry will be indistinguishable from a successful run.
Hijacking the JSX Transform
Tell the build tool to transform JSX expressions using our own factories:
- A JSX element should turn into a call of
JSX.createElement
, and - a JSX fragment should expand to reference
JSX.Fragment
.5
If you use Babel, populate babel.config.js with
module.exports = {
presets: [
[
"@babel/preset-react",
{
pragma: "JSX.createElement",
pragmaFrag: "JSX.Fragment",
},
],
],
};
Or if you use esbuild, populate jsconfig.json with
{
"compilerOptions": {
"jsxFactory": "JSX.createElement",
"jsxFragmentFactory": "JSX.Fragment"
}
}
Then export stub File
and Dir
components for application code to reference:
export const File = () => <></>;
export const Dir = () => <></>;
Our JSX transformer createElement
should handle any File
or Dir
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,
};
createDir
verifies that all JSX children are FileSystem
instances before creating a Dir
:
function createDir(props, children) {
const { name } = props;
children = children.flat();
if (!children.every((child) => child instanceof FileSystem)) {
throw new IllegalArgumentError(
`Children of directory ${name} must be directory or file elements`,
);
}
return new Dir(name, children);
}
createFile
verifies that there is only one JSX child before creating a File
:
function createFile(props, children) {
const { name } = props;
if (children.length !== 1) {
throw new IllegalArgumentError(
`File ${name} must have a single child element or string content`,
);
}
const [content] = children;
if (React.isValidElement(content)) {
const page = `<!DOCTYPE html>${renderToStaticMarkup(content)}`;
return new File(name, page);
}
return new File(name, content);
}
That's it! We are done 🎉.6
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, class components are somewhat redundant. ↩
-
React favors composition over inheritance. ↩
-
name
is a string for an element with a lowercase name and a JavaScript identifier otherwise. JSX users must ensure the identifier resolves to something sensible. Therefore, start the name of every user-defined React component with a capital letter and ensure it is in scope at call sites. Alternatively, assign the component to a capitalized 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. ↩
-
You did think of writing tests, right? Right? ↩