Categories
Uncategorized

Lightweight rendering of React to strings

For a recent project I needed to be able to render React components (or really, just React-like components) into plain HTML strings. Of course, React provides a mechanism to do this: renderToStaticMarkup in its ReactDOM library. But wow, that library is a really big dependency to import if I don’t want any of the DOM reconciliation stuff.

Also, it turns out I don’t even need React lifecycle events for my project. I don’t need component state either. I just want to render a bunch of stateless components with props and children. Something like this:

import React from 'react';
export function Header( { title, subtitle, className } ) {
return (
<div className={ className }>
<h1>{ title }</h1>
<span>{ subtitle }</span>
</div>
);
}

Well, maybe there’s a way to just import part of the rendering engine… but then I realized I had yet another requirement: I need to be able to modify the string version of every component as it is created. I can’t do that with React. I need a custom renderer.

Happily, with some experimentation I learned that it’s not that hard to create one! Below you can see my version of renderToString(). It accepts both stateless functional components and component classes.

Of course, the version below is a bit naive. I’m certain there’s many edge cases of rendering which it does not cover, and like I said above, it does not support state or lifecycle events at all. That said, it works very well for my own purposes and I learned a lot about how React components are put together in the doing!

The following also includes a full test suite to show how it works.

/* globals describe, it */
import React from 'react';
import { expect } from 'chai';
// Using React.createElement works too!
import { renderToString, createElement } from './renderer';
describe( 'renderToString()', function() {
it( 'returns a string with a tag wrapping plain text for a component with one text child', function() {
const component = createElement( 'p', null, 'hi' );
const out = renderToString( component );
expect( out ).to.equal( '<p>hi</p>' );
} );
it( 'returns a string with a tag wrapping plain text for a function component', function() {
const MyEm = ( { children } ) => createElement( 'em', null, children );
const component = createElement( MyEm, null, 'hi' );
const out = renderToString( component );
expect( out ).to.equal( '<em>hi</em>' );
} );
it( 'returns a string with a tag wrapping a function component for a function component', function() {
const MyEm = ( { children } ) => createElement( 'em', null, children );
const component = createElement( MyEm, null, createElement( MyEm, null, 'hi' ) );
const out = renderToString( component );
expect( out ).to.equal( '<em><em>hi</em></em>' );
} );
it( 'returns a string with a tag wrapping plain text for an object component', function() {
class MyStrong extends React.Component {
render() {
return createElement( 'strong', null, this.props.children );
}
}
const component = createElement( MyStrong, null, 'hi' );
const out = renderToString( component );
expect( out ).to.equal( '<strong>hi</strong>' );
} );
it( 'returns a string with a tag wrapping joined text for a component with many text children', function() {
const component = createElement( 'p', null, [ 'hello', 'world' ] );
const out = renderToString( component );
expect( out ).to.equal( '<p>helloworld</p>' );
} );
it( 'returns a string with a tag wrapping another tag for a component with one component child', function() {
const child = createElement( 'em', null, 'yo' );
const component = createElement( 'p', null, child );
const out = renderToString( component );
expect( out ).to.equal( '<p><em>yo</em></p>' );
} );
it( 'returns a string with a tag wrapping other tags for a component with many component children', function() {
const child = createElement( 'em', null, 'yo' );
const text = createElement( 'span', null, 'there' );
const component = createElement( 'p', null, [ child, text ] );
const out = renderToString( component );
expect( out ).to.equal( '<p><em>yo</em><span>there</span></p>' );
} );
it( 'returns a string with a tag wrapping other tags and text for a component with component and text children', function() {
const child = createElement( 'em', null, 'yo' );
const component = createElement( 'p', null, [ child, ' there' ] );
const out = renderToString( component );
expect( out ).to.equal( '<p><em>yo</em> there</p>' );
} );
it( 'returns a string with a tag that expands its props to attributes for a text component', function() {
const component = createElement( 'a', { href: 'foo', target: 'top' }, 'link' );
const out = renderToString( component );
expect( out ).to.equal( '<a href="foo" target="top">link</a>' );
} );
it( 'returns a string with a tag that expands its className prop to a class attribute for a text component', function() {
const component = createElement( 'a', { className: 'cool' }, 'link' );
const out = renderToString( component );
expect( out ).to.equal( '<a class="cool">link</a>' );
} );
it( 'returns a string with a tag that uses its props to create a child for a function component', function() {
const MyEm = ( { name } ) => createElement( 'em', null, name );
const component = createElement( MyEm, { name: 'hi' } );
const out = renderToString( component );
expect( out ).to.equal( '<em>hi</em>' );
} );
} );

view raw
renderer-test.js
hosted with ❤ by GitHub

export function createElement( type, props = null, children = null ) {
if ( ! props ) {
props = {};
}
if ( children ) {
props.children = children;
}
return { type, props };
}
export function renderToString( component ) {
if ( component.length ) {
return component;
}
return renderNonText( component );
}
function renderNonText( component ) {
if ( typeof component.type !== 'function' ) {
return renderHtmlTag( component.type, component.props, component.props.children );
}
if ( component.type.prototype.render ) {
return renderObject( component.type, component.props );
}
return renderFunction( component.type, component.props );
}
function renderChildren( children ) {
return getChildrenArray( children ).map( child => renderToString( child ) ).join( '' );
}
function renderFunction( func, props ) {
return renderToString( func( props ) );
}
function renderObject( Type, props ) {
const instance = new Type( props );
return renderToString( instance.render() );
}
function renderHtmlTag( type, props, children ) {
return children ? `<${ type + renderPropsAsAttrs( props ) }>${ renderChildren( children ) }</${ type }>` : `<${ type + renderPropsAsAttrs( props ) } />`;
}
function renderPropsAsAttrs( props ) {
if ( ! props ) {
props = {};
}
const exclude = [ 'children' ];
const keys = Object.keys( props ).filter( key => exclude.indexOf( key ) === 1 );
if ( keys.length < 1 ) {
return '';
}
return ' ' + keys.map( attr => renderPropAsAttr( attr, props[ attr ] ) ).join( ' ' );
}
function renderPropAsAttr( prop, value ) {
const attr = prop === 'className' ? 'class' : prop;
return `${ attr }="${ value }"`;
}
function getChildrenArray( children = [] ) {
return Array.isArray( children ) ? children : [ children ];
}

view raw
renderer.js
hosted with ❤ by GitHub

export function createElement( type, props = null, children = null ) {
if ( ! props ) {
props = {};
}
if ( children ) {
props.children = children;
}
return { type, props };
}
export function renderToString( component ) {
if ( component.length ) {
return component;
}
return renderNonText( component );
}
function renderNonText( component ) {
if ( typeof component.type !== 'function' ) {
return renderHtmlTag( component.type, component.props, component.props.children );
}
if ( component.type.prototype.render ) {
return renderObject( component.type, component.props );
}
return renderFunction( component.type, component.props );
}
function renderChildren( children ) {
return getChildrenArray( children ).map( child => renderToString( child ) ).join( '' );
}
function renderFunction( func, props ) {
return renderToString( func( props ) );
}
function renderObject( Type, props ) {
const instance = new Type( props );
return renderToString( instance.render() );
}
function renderHtmlTag( type, props, children ) {
return children ? `<${ type + renderPropsAsAttrs( props ) }>${ renderChildren( children ) }</${ type }>` : `<${ type + renderPropsAsAttrs( props ) } />`;
}
function renderPropsAsAttrs( props ) {
if ( ! props ) {
props = {};
}
const exclude = [ 'children' ];
const keys = Object.keys( props ).filter( key => exclude.indexOf( key ) === 1 );
if ( keys.length < 1 ) {
return '';
}
return ' ' + keys.map( attr => renderPropAsAttr( attr, props[ attr ] ) ).join( ' ' );
}
function renderPropAsAttr( prop, value ) {
const attr = prop === 'className' ? 'class' : prop;
return `${ attr }="${ value }"`;
}
function getChildrenArray( children = [] ) {
return Array.isArray( children ) ? children : [ children ];
}

view raw
renderer.js
hosted with ❤ by GitHub

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s