Neonics Repository

A Lightweight JSX Library

import JSX from 'jsx';

document.body.append(<Greeter/>);

const Greeter = () => <h1>Hello world!</h1>;

TOC

Key features:

This package is mostly React compatible, except for some notable differences: - no key attribute required - no useState (instead use this.state.foo and setState({foo: ...})) - component child nodes are passed as the 2nd parameter, not in props.children - only one optional lifecycle event: attach

This package does not enforce composition over inheritance, so the JSX.Component class can be subclassed and methods overriden and used as a new base class for components.

Motivation

This lightweight library was conceived to support a gradual migration path for a large legacy website using HTML templates and jQuery to using JSON and JSX. It was essential that both the old and new code could be used simultaneously as the site was too large to completely refactor in a short period of time.

It soon became clear that React was unsuitable for this scenario due to its use of a Virtual DOM which made injecting HTML fetched using a REST API and manipulating it with jQuery or the DOM API extremely cumbersome if not impossible.

As I searched for a way to be able to use JSX syntax without needing the React library I came across two articles that helped lay the foundation.

Configuration

The following sections list configuration settings for various transpilers, bundlers and linters:

Webpack

Besides the below configuration in webpack.config.js, a babel.config.json is also needed.

resolve: {
    extensions: [
        ...,
        ".jsx",
        ".tsx",
    ]
},
module: {
    rules: {
        {
            test: /\.(jsx|tsx|ts)$/,
            use: { loader: 'babel-loader' }
        },
    }
}

RSPack

Also see JSX transpilation at rspack.dev.

rspack.config.ts:

import { defineConfig } from "@rspack/cli";
export default defineConfig({
    ...
    resolve: {
        extensions: ["...", ".ts", ".tsx", ".jsx"],
    },
    module: {
        rules: {
            {
                test: /\.jsx$/, // or /\.tsx$/
                use: {
                    loader: 'builtin:swc-loader',
                    options: {
                        jsc: {
                            parser: {
                                syntax: 'ecmascript', // or typescript
                                jsx: true,
                            },
                            transform: {
                                react: {
                                    pragma: 'JSX.createElement',
                                    pragmaFrag: 'JSX.createFragment',
                                    throwIfNamespace: true,
                                    development: false,
                                    useBuiltins: false,
                                },
                            },
                        },
                    },
                },
                type: 'javascript/auto',
            },
            ...
        }
    }
});

Note: when using rspack.config.ts be sure to also add the following to tsconfig.json:

    "ts-node": {
        "compilerOptions": {
            "module": "CommonJS"
        }
    }

Typescript

tsconfig.json:

    "compilerOptions": {
        "jsx": "react", // or "preserve"
        "jsxFactory": "JSX.createElement",
        "jsxFragmentFactory": "JSX.createFragment",
        "jsxImportSource": "jsx", //  optional
    }

It is recommended to also add the following to compilerOptions to prevent tsc from eliding import JSX from 'jsx'; in cases where JSX is not referenced:

        "verbatimModuleSyntax": true,

Babel

babel.config.js or similar:

    "plugins": [
        ["@babel/plugin-transform-react-jsx", {
            "pragma": "JSX.createElement",
            "pragmaFrag": "JSX.createFragment"
        }],
        ["@babel/plugin-syntax-jsx"]
    ]

ESLint

.eslintrc.js:

    parserOptions: {
        ecmaFeatures: {
            jsx: true,
        },
    },
    plugins: ["react"],
    settings: {
        react: {
            version: "18",
            pragma: "JSX",
            fragment: "createFragment",
        }
    },
    rules: {
        "react/prop-types" : "off",
        "react/jsx-key": "off",
        "react/no-unknown-property": ["error", {
            // override some html properties that are just too annoying to change to camelCase
            ignore: ["class", "maxlength", "for", "readonly", "tooltip", "onattach", "onAttach", "onActiveTab", "onClose", "props", "autocomplete", "autofocus", "contenteditable"]
        }],
    }

The above rules allow for an easy migration from HTML templates, but they can be made more strict if desired.

Examples

The most basic usage

import JSX from 'jsx';

document.body.append(<h1>Hello world!</h1>)

Simple component function

These are useful for very simple components that don’t need setState or re-rendering. Lambda’s will work as well, as they are not instantiated with new, like component classes.

import JSX from 'jsx';

document.querySelector('#app').append(<MyFunctionComponent>);

function MyFunctionComponent(props) {
    return <div>Function component, {props.foo}</div>
}

Complex Class component

This example illustrates most features.

import JSX from 'jsx';

JSX.createRoot( document.querySelector('#app') ).appendChild(
    <MyClassComponent bar="baz">
        <div>a child</div>
        <div>another child</div>
    </MyClassComponent>
);

class MyClassComponent extends JSX.Component {
    constructor(props, children) {
        super(props, children);
        this.state = { counter: 0 };
    }

    onAttach() {
        this.state.loaded ||
        setTimeout(() => this.setState({loaded: true, data: "Some fetched data"}), 1000);
    }

    onInput(event) {
        this.element.querySelector('.uppercased').innerHTML = event.currentTarget.value.toUpperCase()
    }

    render() {
        return (
            <div onAttach={() => this.onAttach()}>
                <div>Data: {this.state.data ?? 'loading...'}</div>
                <div>{this.children}</div>
                <a onClick={() => this.setState({counter: this.state.counter+1})}>counter: {this.state.counter}</a>
                <div>
                    <input type="text" onInput={event => this.onInput(event)}/>
                    <span class="uppercased"></span>
                </div>
            </div>
        );
    }
}

Using JSX.createRoot(element) will trigger an attach event on the component’s root element. This is useful for components that load server-side data (simulated here with setTimeout) but should be rendered immediately to improve UX feedback.

Child nodes of a component are passed as the second parameter, not in props.children, and are available in the children property.

Calling setState will update the state and re-render the component.

The onInput event and method illustrate how to fetch the value from an input element using event.currentTarget, and how to access the component’s root element via this.element and update the DOM without triggering a re-render, which prevents losing focus.

Common issues

Using JSX from JS

A project may consist of .js and .jsx files (or .ts and .tsx), and typically, only the .jsx/.tsx files will have JSX syntax enabled in the transpiler/bundler. There are many ways to use JSX components from plain JS. A simple pattern is the following:

index.js:

import MyComponent from './MyComponent.jsx';
MyComponent.inject(document.body);

MyComponent.jsx:

import JSX from 'jsx';
export default class MyComponent extends JSX.Component {
    static inject(element) {
        JSX.createRoot(element).appendChild(<MyComponent/>)
    }
    ...
}

Forgetting scroll position on re-render

This can be remedied by overriding the setState method with something like this which gets the scroll positions for elements with a class remember-scroll and restores them after the re-render:

    setState(partialState, callback) {
        let scrollPositions = [...this.element?.querySelectorAll('.remember-scroll')].reduce((acc, cur, idx) => (acc[idx] = cur.scrollTop, acc), []);
        super.setState(partialState, callback);
        this.element?.querySelectorAll('.remember-scroll').forEach((el, idx) => el.scrollTop = scrollPositions[idx]);
    }

Losing input focus on re-render

There are two ways. First, prevent a re-render and do a dynamic DOM update using the DOM API. Second, if possible, split off components that should be rerendered, and trigger a re-render on that component. For example:

class MyParentComponent extends JSX.Component {
    render() {
        return (
            <div>
                <input type="text" onInput={event =>
                    this.element.querySelector('.child').component.setState({
                        input: event.currentTarget.value
                    })
                }/>
                <MyChildComponent class="child"/>
            </div>
        )
    }
}