A lightweight custom JSX runtime that works as a web front-end framework. Inspired by the principles of Solid.js, Plastic skips the Virtual DOM entirely and instead creates real DOM nodes directly, with fine-grained reactivity driven by alien-signals.
- Client-side only (CSR) — Plastic is designed for browser runtime usage and does not include or plan Server-Side Rendering (SSR) support.
- No Virtual DOM — JSX compiles directly to DOM creation calls; no diffing, no reconciliation overhead.
- Fine-grained reactivity — powered by
alien-signals(signal,computed,effect). - Familiar JSX syntax — drop-in JSX transform compatible with Vite and Babel.
- Event binding —
onXxxprops map toaddEventListenerautomatically. - Style objects — pass a plain object to the
styleprop, including CSS custom properties. - Fragment support — return multiple root nodes without a wrapper element.
<Either>conditional rendering — lazily renders only the active branch (<True>/<False>) via a comment-node anchor; inactive branches are never evaluated until the condition flips.<Loop>list rendering — reconciles lists by object identity; reuses, moves, and disposes item rows with fine-grained owner tracking.- Client-side routing —
<Router>,<Route>,<Link>,<NavLink>,navigate(),<Outlet>, anduseRoute()use the History API for nested routing with params and query awareness.
Plastic enforces a strict one-way data flow contract: data travels downward from parent to child through props, and upward only through explicit callbacks or shared reactive state. A child component must never mutate its own props.
When a JSX element compiles to the general jsx() path (see JSX Compilation Paths — i.e. components, <Dynamic>, fragments, or intrinsics with a spread / non-literal attribute), the Babel plugin compiles all props into a single mergeProps(...) call. The result is a read-only Proxy — any attempt to write a prop throws immediately:
const Child = (props) => {
props.label = 'override' // Error: mergeProps result is read-only
}This turns a common accidental mistake into an explicit runtime error instead of a silent data corruption.
Dynamic attribute expressions are compiled into getter properties so that reading a prop inside a reactive binding effect automatically subscribes to its signal dependencies:
// Source
<MyComp foo={2} bar={state.b}>{kid}</MyComp>
// Compiled
jsx(MyComp, mergeProps({
foo: 2,
get bar() { return state.b },
get children() { return kid },
}))Reading props.bar inside an effect invokes the getter, which reads state.b and registers a subscription. When state.b changes, only the binding that reads props.bar re-executes — no component re-render, no diffing.
Spread attributes whose value is a dynamic expression (a call, a member access, etc.) are wrapped in a thunk argument to mergeProps so that the spread source is re-evaluated lazily on each reactive read:
// Source
<MyComp {...api()} foo={2} bar={state.b} />
// Compiled
jsx(MyComp, mergeProps(
() => api(),
{ foo: 2, get bar() { return state.b } },
))Signal reads inside api() are tracked by the same binding effect that reads the resulting prop, so changes inside api() automatically propagate to the DOM without any extra wiring.
Plastic's mergeProps shares the same surface API as Solid's but differs in three important ways:
- Always a Proxy. Solid returns a plain
{}for static plain-object sources to avoid Proxy overhead. Plastic always returns a Proxy. - Thunk-based reactivity, not
createMemo. Solid wraps function sources increateMemoduring initialisation so they are only re-executed when their signals change. Plastic calls the function on every property access; signal tracking still works, but there is no memoisation between reads. - Special-key merging. Plastic adds merging semantics for four key families that Solid does not have:
| Key | Plastic behaviour |
|---|---|
class / className |
treated as one family; all string values concatenated with a space |
style |
plain objects shallow-merged; strings joined with ; |
ref |
last source wins (matches Solid) |
onXxx event handlers |
last source wins (matches Solid) |
class and style receive additive merging so host components can contribute tokens and styles alongside the consumer's without clobbering. ref and onXxx follow Solid's last-wins rule.
Plastic and Solid diverge significantly on how class / className is resolved when multiple sources are present:
- Solid has two distinct modes selected at compile time:
- Merging mode (no spread present): static and dynamic class attributes are concatenated into a single space-separated string.
- Assignment mode (any spread present): the compiler switches to sequential
element.className = valueassignment, so the last class-bearing prop or spread wins and any earlier class declarations are overwritten.
- Plastic has only one mode. Whenever an element compiles to the
jsx()path (which any spread forces it into — see JSX Compilation Paths), the Babel plugin hands every attribute — static, dynamic, and spread alike — to the runtimemergePropsunchanged, andmergePropsperforms the merge. All three source types are concatenated additively, and each source's value may itself be either a string or an object (e.g.{ foo: true, bar: isActive() }); both forms are normalized and merged into the final class list.
In short: introducing a spread in Solid can silently erase previously declared classes; in Plastic the same code keeps all contributions and combines them. This makes host components' class contributions safe under composition without the consumer having to know whether spreads are involved downstream.
The Babel plugin rejects duplicate attribute names on the same JSX element at compile time, so typos and accidental overrides surface as build errors rather than silent last-wins behaviour at runtime.
- Scope is the element itself, not a syntactic group. Detection spans every attribute on the opening tag regardless of
static/ dynamic / spread interleaving — a spread sitting between two same-named attributes does not hide the duplicate. Children are not part of the scope; nested elements have their own independent check. - Spreads do not contribute names. Their contents are dynamic and resolved by
mergePropsat runtime, so they cannot be statically diffed against named attributes. - Whitelist:
class,className,style. These keys have first-class additive merge semantics inmergeProps(see the table above), so repeating them is a legitimate composition pattern, not author error.
<div id="a" id={dynamic} /> // ❌ compile error: duplicate "id"
<div id="a" {...rest} id={dynamic} /> // ❌ compile error: still duplicate
<div class="a" class={dynamic} /> // ✅ class is whitelisted
<div class="a" {...rest} class={dynamic} /> // ✅ both contributions are mergedonMountcallbacks run in child-first order for nested component trees.- Component unmount also follows child-first order.
- During unmount, owner-scoped
effectsare stopped before owner cleanups are flushed, so reactive subscriptions and event bindings are disposed predictably.
- Reactive DOM props: signals, computed values, and getter sources can drive common DOM props (for example
value,title,disabled,placeholder) through a shared binding path. - Reactive
className: class tokens are added and removed incrementally so stale class names are cleaned up when state changes. - Reactive
styleobject: style keys are diffed by key; removed keys are cleared from the element to avoid stale inline styles. - Event binding is one-time: intrinsic
onXxxprops are bound as plain handlers when the node mounts. The reactive Babel transform intentionally does not wrap event expressions into thunks, so patterns likeonClick={flag() ? fnA : fnB}do not rebind whenflagchanges. - JSX-to-DOM prop normalization: camelCase JSX props like
autoFocus,autoComplete,autoPlay,encType, andhrefLangare normalized to the browser-exposed DOM keys before apply. - Mount/dispose API:
renderApp(container, node)returns an idempotent disposer that unmounts DOM and disposes owner/effect scopes. - Lifecycle hooks:
onMountand cleanup registration (onCleanupwrapper) are available for component-level setup and teardown.
The @plastic-js/babel-preset-plastic reactive transform inspects every JSX element and lowers it to one of four runtime shapes. Paths are tried top-to-bottom; the first match wins. Earlier paths are strictly cheaper at runtime, so the transform always picks the most specialized form an element qualifies for.
The plugin's decision cascade in one line:
if (foldable) { if (ops == 0) cloneNode else IIFE } else if (jsxStatic) ... else generic
Paths 1 and 2 share the same foldable branch in the source — they differ only by whether the template has dynamic holes (plan.ops.length). Paths 3 and 4 are the remaining else if / else arms.
Trigger: an entire subtree is "foldable" — intrinsic lowercase tags only, no spread attrs, no namespaced attr names, and every attribute value and child (recursively) is a scalar literal. No dynamic holes anywhere.
Emitted code:
const _tmpl$ = /*#__PURE__*/ template('<div class="row"><span>hello</span></div>')
// per render site:
_tmpl$.cloneNode(true)Runtime cost per instance: one native cloneNode(true). The DOM skeleton is parsed once at module load via innerHTML; no createElement, no per-attribute writes, no per-child append.
Trigger: foldable skeleton (same rules as path 1) but with dynamic attribute values and/or dynamic children. Static parts fold into the template string; dynamic parts become setProp / insert ops.
Emitted code:
const _tmpl$ = template('<div class="row"><span></span></div>')
// per render site:
(() => {
const _el = _tmpl$.cloneNode(true)
const _span = _el.firstChild
insert(_span, () => count())
return _el
})()Runtime cost per instance: cloneNode(true) + N hole patches. Skips createElement and applyProps for the static skeleton; only the dynamic holes pay reactive-wrapper cost.
Trigger: lowercase intrinsic tag, no spread, every attribute value is a scalar literal (string / number / boolean / null). Children are unconstrained — they pass through the normal jsx pipeline as the third argument, so dynamic / component / nested-JSX children are fine. Reached when path 2's whole-subtree foldability declined (typically because babel chose the single-call form for a standalone element).
Emitted code:
jsxStatic('div', { class: 'row' }, () => count())Runtime cost per instance: createElement + one direct DOM write per attribute (no isReactive check, no binding-effect wrapper, no mergeProps proxy). Children flow through appendChild's usual reactive/defer paths.
Trigger: everything else. In particular:
- Capitalized tag (
<Foo />) → component invocation JSXMemberExpression(<obj.Foo />)<Dynamic component={...} />- Fragment (
<>...</>) - Intrinsic tag with any spread attribute
- Intrinsic tag with no spread but at least one non-literal attribute value (and the subtree wasn't foldable for path 2)
Emitted code:
jsx(Tag, mergeProps({ class: 'row' }, () => ({ id: id() }), { children: ... }))Runtime cost per instance: full reactive dispatch — mergeProps proxy, per-prop isReactive / binding-effect wrapping, applyProps with JSX_PROP_MAP lookups, recursive child handling.
| # | Path | Tag | Spread allowed | Attr values | Children | Per-instance work |
|---|---|---|---|---|---|---|
| 1 | template (pure) | intrinsic | no | all literal | all literal (recursive) | cloneNode |
| 2 | template + holes | intrinsic | no | any | any | cloneNode + hole ops |
| 3 | jsxStatic |
intrinsic | no | all scalar literal | any | createElement + direct attr writes |
| 4 | jsx / mergeProps |
any | yes | any | any | full reactive dispatch |
Rule of thumb: non-intrinsic tags always go through path 4. The fast paths exist only because lowercase intrinsics map 1:1 to DOM elements and don't need component invocation or descriptor materialization.
Plastic's reactivity layer is built on top of alien-signals and extends it with deep object reactivity. All primitives are exported from jsx.
Creates a reactive container for a single value. Reading the signal inside an effect or createComputed subscribes to it; writing triggers updates.
import { createSignal, effect } from 'jsx'
const count = createSignal(0)
effect(() => console.log(count())) // logs 0
count(1) // logs 1Passing an existing signal returns it unchanged — double-wrapping is a no-op.
When a signal is used directly as a JSX child, the two forms below are behaviorally equivalent — both produce a reactive child that re-renders when the signal updates — but the bare-identifier form is the recommended style:
const count = createSignal(0)
<span>{count}</span> // ✅ Recommended — Identifier passed through as-is (signal is itself a function)
<span>{count()}</span> // ⚠️ Discouraged — CallExpression that Babel must wrap as `() => count()`Why they behave the same:
- The
@plastic-js/babel-preset-plasticreactive transform classifies identifiers as static and emits them unchanged, while call expressions are not static and get wrapped in a thunk (() => count()). - At runtime,
appendChilddetects any child whosetypeof === 'function'and routes it through the reactive child path — creating a placeholder, subscribing to signal reads, and patching the DOM on change.
Why prefer {count}:
- One fewer function call per update (the
{count()}form invokes the thunk, which then invokes the signal). - Slightly smaller compiled output (no synthesized
() => ...wrapper). - Makes the "signal flows through as a reactive citizen" model explicit at the call site, instead of looking like an eager read.
This equivalence relies on
countbeing a signal (a callable function). For a plain variable (const count = 2),{count}is a static value and{count()}would throw — they are not interchangeable in that case.
Creates a lazily-evaluated derived value. The computation re-runs only when its signal dependencies change.
import { createSignal, createComputed } from 'jsx'
const firstName = createSignal('Jane')
const lastName = createSignal('Doe')
const fullName = createComputed(() => `${firstName()} ${lastName()}`)
fullName() // 'Jane Doe'Wraps a plain object (or array) in a deep-reactive Proxy, equivalent to Vue 3's reactive(). Every property read subscribes to that property's signal; every write triggers only the affected property's subscribers.
import { createTree, effect } from 'jsx'
const state = createTree({ user: { name: 'Alice' }, count: 0 })
effect(() => console.log(state.user.name)) // logs 'Alice'
state.user.name = 'Bob' // logs 'Bob'Nested objects are wrapped on demand when accessed, so reactivity is deep without upfront cost. Calling createTree on an already-reactive tree is a no-op.
Runs fn immediately and re-runs it whenever any signal read inside it changes.
import { createSignal, effect } from 'jsx'
const x = createSignal(1)
effect(() => console.log('x is', x()))
x(2) // logs 'x is 2'Effects are automatically disposed when their owner scope is cleaned up (for example, when a component unmounts).
Defers all signal notifications until fn returns, so multiple writes trigger only one downstream update.
import { createSignal, createComputed, batch } from 'jsx'
const a = createSignal(1)
const b = createSignal(2)
const sum = createComputed(() => a() + b())
batch(() => {
a(10)
b(20)
})
// sum re-computes once, not twice| Combination | Allowed | Notes |
|---|---|---|
signal → primitive |
Yes | Normal usage |
signal → tree |
Yes | Signal controls which object; tree tracks its properties |
signal → function |
Yes | Signal controls which computation to use |
signal → signal |
No | Forbidden — createSignal returns the inner signal as-is |
signal → computed |
Discouraged | Triggers a runtime warning; use the computed directly |
isSignal(value)— returnstrueifvalueis a signal.isComputed(value)— returnstrueifvalueis a computed.isTree(value)— returnstrueifvalueis a reactive tree proxy.toRaw(value)— unwraps a reactive tree proxy to its underlying plain object. Safe to call on non-proxy values (returns the value unchanged).runUntracked(fn)— runsfnwithout registering any signal subscriptions. Useful when reading reactive state for a one-off value without creating a dependency.
import { createTree, toRaw, isTree } from 'jsx'
const state = createTree({ x: 1 })
isTree(state) // true
toRaw(state) // { x: 1 }- Node.js 18+
- npm 9+
Install Plastic together with its Babel toolchain. Plastic's JSX compiles in two stages: @babel/preset-react turns JSX into jsx(...) calls against Plastic's runtime, then @plastic-js/babel-preset-plastic rewrites those calls for fine-grained reactivity (control-flow lifting, mergeProps, etc.).
npm install @plastic-js/plastic
npm install --save-dev \
@babel/core \
@babel/preset-react \
@plastic-js/babel-preset-plastic \
vite-plugin-babelThen wire the presets up in vite.config.js:
import { defineConfig } from 'vite'
import babel from 'vite-plugin-babel'
import plasticJsx from '@plastic-js/babel-preset-plastic'
export default defineConfig({
plugins: [
babel({
babelConfig: {
presets: [
['@babel/preset-react', {
runtime: 'automatic',
importSource: '@plastic-js/plastic',
}],
plasticJsx,
],
},
}),
],
})The importSource: '@plastic-js/plastic' option points the JSX runtime at Plastic instead of React, so jsx/jsxs/Fragment are imported from @plastic-js/plastic/jsx-runtime.
Plastic ships with a lightweight client-side router built on top of the browser History API.
Hash-based routing is intentionally not supported at the moment. To keep the router implementation small and predictable, Plastic currently supports only the History API.
<Router>owns the current location signal and listens to browser navigation.<Route path="...">renders only the active branch.<Link to="...">renders a normal anchor and intercepts internal left-click navigation.<NavLink to="...">extends<Link>and adds anactiveclass plusaria-current="page"when the target matches the current route.useMatch(path)returns a reactive matcher function for custom active-state UI without rendering<NavLink>.navigate(to, options)performs programmatic navigation.<Outlet />renders the currently matched child route inside a parent route component.lazy(importFn, options?)wraps a dynamic import as a code-split component for use with<Route>.
Use <NavLink> when a navigation item should reflect the current route automatically.
import { NavLink, Route, Router } from 'jsx'
const App = ()=> (
<Router>
<nav>
<NavLink to='/'>Home</NavLink>
<NavLink to='/settings' className='nav-item'>Settings</NavLink>
</nav>
<Route path='/' component={HomePage} />
<Route path='/settings' component={SettingsPage} />
</Router>
)By default, NavLink treats nested URLs as active matches, so a link to /settings stays active on /settings/profile. Pass end to require an exact pathname match instead. Use activeClass to override the default active class name.
Use useMatch(path) when you want route-aware active styles on non-anchor UI (tabs, cards, badges, etc.).
import { Router, Route, useMatch } from 'jsx'
const DashboardTabs = ()=> {
const isSettings = useMatch('/settings')
const isUser = useMatch('/users/:id')
return (
<div>
<p className={isSettings() ? 'on' : 'off'}>Settings</p>
<p className={isUser() ? 'on' : 'off'}>User</p>
</div>
)
}
const App = ()=> (
<Router>
<Route path='*' component={DashboardTabs} />
</Router>
)useMatch('/settings') follows NavLink default active behavior (prefix match), while parameterized paths like /users/:id use exact segment-shape matching.
Nested routes are declared by placing child <Route> elements inside a parent <Route>. Parent route components render their active child branch through <Outlet />.
import {
Link,
Outlet,
Route,
Router,
navigate,
} from 'jsx'
const Settings = ()=> (
<div>
<h2>Settings</h2>
<nav>
<Link to='/settings'>Overview</Link>
<Link to='/settings/profile'>Profile</Link>
<Link to='/settings/security'>Security</Link>
</nav>
<Outlet />
</div>
)
const SettingsOverview = ()=> <p>Overview page</p>
const SettingsProfile = ()=> <p>Profile page</p>
const SettingsSecurity = ()=> <p>Security page</p>
const App = ()=> (
<Router>
<Route component={Settings} path='/settings'>
<Route index component={SettingsOverview} />
<Route component={SettingsProfile} path='/profile' />
<Route component={SettingsSecurity} path='/security' />
</Route>
</Router>
)
navigate('/settings/profile')- Leaf routes use exact path-shape matching (including
:paramsegments). - Parent routes that declare child
<Route>elements use prefix matching so they stay mounted while nested child routes switch underneath them. - Nested child paths are resolved relative to their parent route. For example, a child
path='/profile'inside a parentpath='/settings'matches/settings/profile. indexroutes match the parent path itself and render through the parent component's<Outlet />.- Query strings are exposed through
useRoute().queryand route props (query) without affecting path matching.
lazy(importFn, options?) code-splits a route component via a dynamic import(). The module is fetched the first time the route is rendered; subsequent renders reuse the cached result synchronously. Because the resolved component is stored in a signal, the route automatically re-renders once the import settles — no manual wiring required.
import { lazy, Route, Router } from 'jsx'
// Each call to lazy() creates an independent, deduplicated import.
const LazyDashboard = lazy(() => import('./pages/Dashboard.jsx'))
const LazySettings = lazy(() => import('./pages/Settings.jsx'))
const App = ()=> (
<Router>
<Route component={LazyDashboard} path='/dashboard' />
<Route component={LazySettings} path='/settings' />
</Router>
)With a loading fallback
Pass a fallback option to render a placeholder component while the import is in flight:
const Spinner = ()=> <p>Loading…</p>
const LazyDashboard = lazy(
()=> import('./pages/Dashboard.jsx'),
{ fallback: Spinner },
)fallback can be a component function (called with no props) or a pre-created DOM node / null. When omitted, the route renders nothing during loading.
API
| Argument | Type | Description |
|---|---|---|
importFn |
() => Promise<module> |
Zero-argument factory. The module's default export is used as the component. |
options.fallback |
Component | Node | null |
Shown while the import is in flight. Defaults to null. |
The returned
LazyComponentfunction is a plain component and can also be used outside of routes — anywhereh(LazyComponent, props)is valid.