How to render JSX to whatever you want with a custom JSX Renderer
Adrian
Posted on December 4, 2023
Most of the time, you don't need to write your own JSX renderer. But what if you needed to? Well, this guide will show you how to do it.
It's actually surprisingly simple! So let's dive in.
TLDR: If you want to see the code straight away, here is the repository: https://github.com/adrian-afl/render-jsx-reupload
What will be needed
You will need TypeScript to follow this guide, and that's it, no external libraries, just your code.
Preparation
Create a project - I mean, with package.json - and install TypeScript.
Then create a tsconfig file, configure it however you want, but in compilerOptions
include those two values:
"jsx": "react-jsx",
"jsxImportSource": "./src/app",
jsx
set to react-jsx
tells the compiler that we want JSX, but we will be using a different implementation than React, in fact, we will write our own. jsxImportSource
set to ./src/app
tells the compiler that this is the place where to find the necessary exported stuff that makes handling JSX possible. Beware, this is a path, and TypeScript will append to it a postfix /jsx-runtime
and will import that file.
Once this is done, let's go to the implementation.
The JSX namespace
Create a file ./src/app/jsx-runtime.ts
. This is the file that TypeScript will actually load to do the JSX stuff.
TypeScript requires you to export very specific things from this file. Those are:
-
JSX
namespace-
IntrinsicElements
type - an object type, with keys being the allowed tags, and values being the type of attributes for tags -
Element
type - can be absolutely anything, defines the type that is the result of the JSX node rendering
-
-
jsx
function - a function which needs a specific arguments and return value type, this is the function that is called by the runtime when rendering JSX nodes. -
jsxs
andjsxDEV
which for now can be aliased to point to the same function asjsx
This is also the place where you can limit what tags are allowed and what attributes are allowed. To limit that:
- To limit what tags are allowed, narrow the type of
IntrinsicElements
key type. - To limit what attributes are allowed, narrow the type of
IntrinsicElements
value type
This limiting is usually done by making IntrinsicElements
an interface looking like that:
export interface IntrinsicElements {
a: AnhorAttributes;
div: DivAttributes;
span: SpanAttibutes;
... and so on
}
And the attribute types are also interfaces, for example
interface AnhorAttributes {
href: string;
target?: string;
}
I just made it in a way that all tags are allowed - by setting the IntrinsicElements
key type to a string
, and all attributes are allowed, by setting the IntrinsicElements
value type to Record<string, JSXNode | undefined>
.
Example in this article renders the JSX nodes to objects with only one field, a string containing the rendered HTML.
Here's how it looks like in this file:
// Set the attributes to allow any keys and very permissive values
export type HTMLAttributes = Record<string, JSXNode | undefined> &
JSXChildren;
namespace JSX {
// Allow any HTML tag
export type IntrinsicElements = Record<string, HTMLAttributes>;
// Declare the shape of JSX rendering result
// This is required so the return types of components can be inferred
export type Element = RenderedNode;
}
// Export the main namespace
export { JSX };
// Export factories
export const jsx = renderJSX;
export const jsxs = renderJSX;
export const jsxDEV = renderJSX;
You can see there are names that are not yet defined, RenderedNode
, JSXNode
and JSXChildren
. Let's explain what they are.
Types
RenderedNode
is the result shape of the rendering, jsx
function returns values that are of this type. I decided to make it an object, actually a class, because if I went with just a string, I wouldn't be able to do proper escaping of the HTML. This is because I would not be able to differentiate between rendered HTML and a string literal. Anyway, here's how it looks like:
export class RenderedNode {
public constructor(public readonly string: string) {}
}
Now, onto JSXChildren. It defines how a children
property looks like. This is the thing that defines what is inside the JSX tags, for example in this JSX:
<div>
Hello
</div>
the string Hello
is the children prop.
export interface JSXChildren {
children?: JSXNode | JSXNode[] | undefined;
}
And now, the big one. JSXNode
defines the values inside JSX attributes and JSX children. I made it pretty permissive about what can be rendered. You can define separate types for values available for attributes and separate for children, I used the same type for both, which allowed me to use the same serializer function.
export type JSXNode =
| RenderedNode
| RawContentNode
| (() => JSXNode)
| boolean
| number
| bigint
| string
| null
| undefined;
While we are on types, let's define 2 additional types that will come in handy soon:
interface RawContentNode {
htmlContent: string;
}
export type FunctionComponent = (
props: Record<string, unknown>
) => RenderedNode;
RawContentNode
is a special object that if passed as a value will be rendered to a string but without any escaping. Handy for CSS and style tags, for example. Obviously we need to handle it, and this is not something standard, I just thought it's necessary, for example to handle <script>
and <style>
tags, and decided this is a cool way to specify it.
FunctionComponent
is a type for a function component, will be used during the rendering.
Rendering
The jsx
function and its friends are mapped to the renderJSX
function. Here's how it looks like:
export function renderJSX(
tag: string | FunctionComponent | undefined,
props: JSX.HTMLAttributes,
_key?: string
): JSX.Element {
if (typeof tag === "function") {
// handling Function Components
return tag(props);
} else if (tag === undefined) {
// handling <></>
return new RenderedNode(renderChildren(props));
} else {
// handling plain HTML codes
return new RenderedNode(
renderTag(tag, renderAttributes(props), renderChildren(props))
);
}
}
Not that complicated, in renderJSX
3 execution paths are possible:
- If
tag
is a function, we got a function component to handle. It's already a function, and we got theprops
that should be passed to it, so just call it with theprops
and return whatever it returns. - If
tag
is undefined, we have a<></>
fragment. It cannot take props, and can only have one or more children. Rendering it boils down to rendering every child inside, we will see soon how it's done. - If
tag
is a string, it's a plain HTML element. Render it using therenderTag
method that I will describe below.
We see that we need 3 helper functions so far, those will again have helper functions, but bear with me.
function renderTag(tag: string, attributes: string, children: string): string {
const tagWithAttributes = [tag, attributes].join(" ").trim();
if (children.length !== 0) {
// render open and close tags
return `<${tagWithAttributes}>${children}</${tag}>`;
} else {
// render only one self-closing tag
return `<${tagWithAttributes}/>`;
}
}
This renderTag
function is straightforward, renders a plain HTML element to a string, using the parameters passed.
function renderChildren(attributes: JSX.HTMLAttributes): string {
const children = attributes.children;
if (!children) {
return "";
}
const childrenArray = !Array.isArray(children) ? [children] : children;
return childrenArray.map((c) => serialize(c, escapeHTML)).join("");
}
renderChildren
functions gets a special attribute named children
from the props object that jsx
got. This attribute, if present, holds whatever is between the JSX tags, like in the example before, <div>Hello<div>
would contain Hello
as children. This might be just one element, then it's not an array, or an array, then it comes in as an array. To handle it easier, transform the value to always be an array, then serialize all elements to strings and join them together.
function renderAttributes(attributes: JSX.HTMLAttributes): string {
return Object.entries(attributes)
.filter((prop) => prop[0] !== "children")
.map((prop) => {
const value = serialize(prop[1], escapeProp);
return `${prop[0]}="${value}"`;
})
.join(" ");
}
This renderAttributes
is responsible for rendering the HTML attributes string that will ultimately make its way into an HTML tag. First, filter out the special children
property, it was handled with renderChildren
. Then with the remaining elements, serialize the value to a string and render the attribute string. Finally, join the resulting strings, but this time with a space, because we need a space between the attributes in final HTML string.
As you can see, there are some other helper functions. First of all we need the serialize
function, its helper type and helper error, here it is:
export class SerializationError extends Error {
public constructor(public readonly invalidValue: unknown) {
super("Invalid value");
}
}
interface RawContentNodeTest {
htmlContent?: string | undefined;
}
export function serialize(
value: JSXNode,
escaper: (value: string) => string
): string {
// Null and undefined handling
if (value === null || value === undefined) {
return "";
}
// String node handling
if (typeof value === "string") {
return escaper(value);
}
// Number node handling
if (typeof value === "number") {
return value.toString();
}
if (typeof value === "bigint") {
return value.toString();
}
// Boolean node handling
if (typeof value === "boolean") {
return value ? "true" : "false";
}
// Function node handling
if (typeof value === "function") {
return serialize(value(), escaper);
}
// RenderedNode node handling
if (value instanceof RenderedNode) {
return value.string;
}
// Dangerous string handling
if (
typeof value === "object" &&
typeof (value as RawContentNodeTest).htmlContent === "string"
) {
return value.htmlContent;
}
throw new SerializationError(value);
}
The error is only for semantics, I like to make custom errors for various occasions. The RawContentNodeTest
is a helper type to test if the value coming in is the special one that should be rendered without escaping special characters.
You can also notice that the function takes a second parameter that will do the escaping. It's a simple function signature that takes a string and returns a string. I decided to make the serializer be the same for HTML content and attributes, but both require slightly different escaping, that's why the second parameter is needed.
So the following happen in this function:
- If the value is
null
orundefined
, return an empty string - If the value is a
string
, escape special characters and return it - If value is a
number
,bigint
orboolean
, convert it to string and return it - If value is a
function
, call it, and pass the result viaserialize
again, then return it - If value is already a
RenderedNode
, extract the content of it and return it - If value is of that special
RawContentNode
type, which is checked by checking that value is anobject
and then the existence and type of value under thehtmlContent
, then return it as is, without escaping - Otherwise, throw and error
What is left to make this implementation complete are the functions that escape special characters inside HTML content and HTML attributes. Those are very simple, look like that:
export function escapeProp(value: string): string {
return value
.replaceAll("&", "&")
.replaceAll('"', """)
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll("\n", " ")
.trim();
}
export function escapeHTML(value: string): string {
return value
.replaceAll("&", "&")
.replaceAll('"', """)
.replaceAll("'", "'")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll("\n", "<br/>")
.trim();
}
Those just replace some stuff inside the strings and return them.
Usage
And here we have it! It will work from now automatically without any special imports or libraries. Just this TypeScript code.
To use it, for example, create a Test.tsx file and put inside:
const ComponentA = (): RenderedNode => <div>Hello</div>;
const ComponentB = (): RenderedNode => (
<span>
<ComponentA />
</span>
);
const rendered = <ComponentB />;
console.log(rendered);
Running this code you will see this printed out:
<div><span>Hello</span></div>
Why would anyone want to do that?
I know preact to string exists, but it is not a fit for some usages because it still assumes you are targeting real HTML. For example, only HTML tags are allowed, and the values and names of attributes are type-checked to be correct HTML. It's not desired sometimes. With this code you don't need to worry about it, you can render the JSX to whatever you want.
I personally need this for an upcoming terminal based UI library that will use JSX to define its content, but that's a story
for another time :)
Thanks for reading, and happy rendering!
Here you can find the code from this article: https://github.com/adrian-afl/render-jsx-reupload
Posted on December 4, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.