Neonics Repository
A Lightweight JSX Library
import JSX from 'jsx';
document.body.append(<Greeter/>);
const Greeter = () => <h1>Hello world!</h1>;TOC
Key features:
- only ~200 lines / ~162 SLOC / 6kb of source code
- no runtime payload (except for 71 bytes of an experimental feature)
- supports both JSX and TSX
- no virtual DOM
- automatic event handler attachment with
on...attributes - supports
class="x",class={["x", ...]}andclassName="x" - supports boolean attributes (
disabled,selected,checked,readonly,required) - supports
styleattribute with CSS variables, e.g.style={{--color: 'red'}} - supports fragments with
<></>syntax (implemented as arrays) Component.elementcontains the rootHTMLElementof the componentHTMLElement.componentcontains the component instance- supports XML namespace via
xmlnsattribute - supports
ref={(element, props) => {}}for custom element modification
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>
)
}
}