@reatom/jsx
JSX
Features
- β‘οΈ Zero re-renders: reactive bindings update DOM directly
- π§© Native elements:
<div />returns a real DOM node - π No extra build step: just TSX and TypeScript support
- π― Tiny footprint: ~3KB runtime (plus a minimal core)
- π¨ Built-in styles: efficient via CSS variables
Installation
npm install @reatom/core @reatom/jsxSet up TypeScript to use the JSX runtime:
tsconfig.json:
{ "compilerOptions": { "moduleResolution": "bundler", "jsx": "preserve", "jsxImportSource": "@reatom/jsx" }}For Vite users:
vite.config.js:
import { defineConfig } from 'vite'
export default defineConfig({ esbuild: { jsxFactory: 'h', jsxFragment: 'hf', jsxInject: `import { h, hf } from "@reatom/jsx"`, },})Framework compatibility
You can integrate @reatom/jsx into existing React or other JSX projects.
- Create a separate package for Reatom-based components
- Extend(https://www.typescriptlang.org/tsconfig#extends) your base
tsconfig.jsonwith JSX config - In each file, declare JSX pragma manually:
// @jsxRuntime classic// @jsx himport { h } from '@reatom/jsx'This enables you to gradually migrate or optimize parts of your app without conflict with existing tooling.
Example
π‘ See a more advanced version with dynamic entities: https://github.com/reatom/reatom/tree/v1001/examples/reatom-jsx
Define a component:
import { atom } from '@reatom/core'
const value = atom('')// Event handlers shouldn't be wrapped, Reatom take care of itconst onInput = (event: Event & { currentTarget: HTMLInputElement }) => value.set(event.currentTarget.value)
const Input = () => <input value={value} on:input={onInput} />Mount your app:
import { connectLogger, context, clearStack } from '@reatom/core'import { mount } from '@reatom/jsx'import { App } from './App'
// Disable default global context to enforce explicit context usage (recommended)clearStack()
// Create a root context for the applicationconst rootContext = context.start()
if (import.meta.env.MODE === 'development') { connectLogger()}
// Mount the app within the created contextconst { unmount } = mount(document.getElementById('app')!, <App />)
// Later, to unmount and cleanup:// unmount()The mount function returns an unmount property callback (similar to Reactβs root.unmount()). When called, it:
- Disconnects the internal MutationObserver
- Unsubscribes from all reactive atoms
- Calls all
refunmount callbacks - Removes the element from the DOM
Hot module replacement (Vite)
During development, the bundler can replace a module without a full reload. The old DOM tree and Reatom subscriptions stay alive unless you tear them down. Call unmount() from the previous mount inside import.meta.hot.accept so the updated module can mount a fresh tree:
const root = document.getElementById('app')!const { unmount } = mount(root, <App />)
if (import.meta.hot) { import.meta.hot.accept(() => { unmount() })}Reference
This package implements a JSX factory that creates and binds native DOM elements with reactivity.
Props
JSX props are treated as follows:
- Default: set as DOM properties or attributes are used depending on the context, to ensure correct behavior and predictable outcomes.
prop:*: sets DOM properties.attr:*: sets DOM attributes.on:*: register event listeners. Functions interacting with Reatom state or actions are wrapped (wrap) automatically.
All values can be:
nullorundefined: removes DOM attribute or resets DOM propertystring,numberorboolean: sets property or attributeAtomLikeor a derived function: tracked reactively, the prop is automatically updated when dependencies change
const enabled = atom(true)const value = atom('')<input value={value} attr:type="text" prop:disabled={() => !enabled()} on:input={(event) => value.set(event.currentTarget.value)}/>Children
The children prop defines element content. It supports:
boolean,null, orundefinedβ renders nothingstringornumberβ renders textNodeβ inserts DOM node as-isAtomLikeβ reactive content
<div>{count}</div>Models
Use model:* props for two-way binding with native input controls and plain writable atoms.
Supported props:
model:value: binds thevalueproperty of inputs, useful for text inputs and<textarea>model:valueAsNumber: binds thevalueAsNumberproperty, typically used for numeric inputsmodel:checked: binds thecheckedproperty of checkboxes or radio buttonsmodel:field: binds areatomField/ atomized field fromreatomForm(change, focus, disabled, elementRef)
const value = atom('')const Input = () => <input model:value={value} />Components run once on creation, so itβs safe to define atoms or any setup logic inside:
const Input = () => { const value = atom('') return <input model:value={value} />}Forms
For real forms β validation, submit, focus/dirty state β use reatomForm from @reatom/core with JSX bindings:
<form model={form}>βpreventDefault, callsform.submit(), togglesdata-submitting/data-submitted/data-submit-error(and matching classes) on the form elementmodel:field={form.fields.name}β wires each input throughfield.changeand focus tracking
Use model:value / model:checked for simple controls (search, toggles). Use model:field for form fields. See the forms handbook.
import { reatomForm } from '@reatom/core'
export const loginForm = reatomForm( { username: '', password: '', passwordDouble: '', }, { validate({ password, passwordDouble }) { if (password !== passwordDouble) { return 'Passwords do not match' } }, onSubmit: async (values) => api.login(values), validateOnBlur: true, name: 'loginForm', },)import { css } from '@reatom/jsx'import { loginForm } from './loginForm'
const formStyles = css` &[data-submitting] [type='submit'] { color: transparent; position: relative; pointer-events: none; } &[data-submitting] [type='submit']::after { content: ''; position: absolute; inset: 0; margin: auto; width: 1em; height: 1em; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin 0.6s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } }`
export const LoginForm = () => ( <form model={loginForm} css={formStyles}> <input model:field={loginForm.fields.username} placeholder="Enter your username" /> <span>{() => loginForm.fields.username.validation().error}</span>
<input model:field={loginForm.fields.password} attr:type="password" placeholder="Enter your password" />
<input model:field={loginForm.fields.passwordDouble} attr:type="password" placeholder="Confirm your password" />
<button type="submit">Login</button> </form>)<form model={form}> wires submit (preventDefault + form.submit()) and toggles data-submitting, data-submitted, and data-submit-error on the form for CSS. The submit button needs no extra props β the spinner above is CSS-only while data-submitting is set.
style props
Use style={{ key: value }} for inline styles. Falsy values like false, null, and undefined remove the style.
<div style={{ top: 0, display: hidden() && 'none' }} />Avoid replacing the full style object β prefer updates via static keys:
β Avoid:
<div style={() => (flag() ? { top: 0 } : { bottom: 0 })} />β Use:
<div style={() => flag() ? { top: 0, bottom: undefined } : { top: undefined, bottom: 0 } }/>style:* props
Set individual styles via style:*:
// <div style="top: 10px; right: 0;"></div><div style:top={atom('10px')} style:right={0} style:bottom={undefined} style:left={null}></div>Values can be primitives or reactive (atom, () => string, etc.). Numbers are passed as-is (no automatic px).
class or className props
The JSX runtime automatically applies the same logic internally using reatomClassName.
/** @example <button class="button button--size-medium button--theme-primary button--is-active"></button> */<button class={[ 'button', `button--size-${props.size}`, `button--theme-${props.theme}`, { 'button--is-disabled': props.isDisabled, 'button--is-active': props.isActive() && !props.isDisabled(), },]}></button>CSS-in-JS
The css prop provides an ideal architectural balance for styling: it keeps styles colocated with the markup they affect while avoiding the coupling issues of other approaches.
Use the css prop to declare styles via tagged template literals. Dynamic values can be passed as CSS variables via css:*.
const Component = () => (<input css:size={size} css="font-size: calc(1em + var(--size) * 0.1em);"></div>)This will be compiled to:
<div data-reatom-style="1" data-reatom-name="Component" style="--size: 3"></div>Behind the scenes, the runtime:
- Generates a unique style ID and sets it as
data-reatom-styleattribute - Inserts the CSS rule once using attribute selector (
[data-reatom-style="1"]) - Applies dynamic values as inline CSS variables (
--size) - Sets
data-reatom-nameattribute with the component name for better DevTools traceability
Why css-prop?
Every styling approach forces trade-offs. CSS-modules and SFC style tags split your component across files. Tailwind requires learning a new vocabulary and clutters your markup. Styled-components and Linaria add runtime overhead or complex build pipelines.
The css prop takes a different path: just write CSS where you use it. No preprocessing, no new syntax to learn β Reatom passes your styles directly to the DOM. You still get native CSS nesting, readable component names in DevTools via data-reatom-name attribute, and zero build complexity.
But the real win is architectural. When styles live inline, growing components naturally want to be extracted as new components β not split into separate style files. This keeps markup and styles together, maintaining high cohesion and low coupling. The path of least resistance leads to better code organization.
Tip: wrapping logic in components improves
data-reatom-namereadability and traceability.
Prettier formats
csstemplate literals correctly, and the vscode-styled-components extension provides syntax highlighting.
CSS Mixins
Since styles are just strings, you can build your own design system with plain JavaScript β no framework magic required.
Mix utilities with custom CSS when needed:
<div css={` ${card} max-width: 400px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); `}/>Use them directly β no template literals needed for simple cases:
<main css={center}> <article css={card}> <h1 css={text(colors.text)}>Hello</h1> <button css={bg(colors.primary) + px(4) + py(2) + rounded}>Click me</button> </article></main>Your design tokens and utilities:
// Tokensexport const colors = { primary: '#3b82f6', surface: '#f8fafc', text: '#1e293b',}export const space = [0, 4, 8, 16, 24, 32, 48] as constexport type Size = 0 | 1 | 2 | 3 | 4 | 5 | 6
// Utilities β just strings and functionsexport const flex = 'display: flex;'export const flexCenter = 'justify-content: center; align-items: center;'export const rounded = 'border-radius: 8px;'
export const p = (n: Size) => `padding: ${space[n]}px;`export const px = (n: Size) => `padding-inline: ${space[n]}px;`export const py = (n: Size) => `padding-block: ${space[n]}px;`export const bg = (color: string) => `background: ${color};`export const text = (color: string) => `color: ${color};`
export const card = bg(colors.surface) + rounded + p(4)export const center = flex + flexCenterThis gives you the composability of utility-first CSS with full CSS power β all type-safe, all just JavaScript.
Conditional Styles with Attributes
We have built-in class names parsing and a reactive helper reatomClassName. But often itβs simpler and more effective to use native attributes for conditional styles β the attribute becomes both the state indicator and the CSS selector target.
const Accordion = ({ title, children }) => { const open = atom(false)
return ( <details open={open} css={` article { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.2s; } &[open] article { grid-template-rows: 1fr; } `} > <summary on:click={open.toggle}>{title}</summary> <article> <div css="overflow: hidden;">{children}</div> </article> </details> )}Works with ARIA and standard attributes β accessible and styleable in one:
// Loading state indicator<nav aria-busy={loading} css="&[aria-busy='true'] { opacity: 0.5; }"> {children}</nav>// Invalid form field<input aria-invalid={hasError} css="&[aria-invalid='true'] { border-color: red; }"/>// Active tab or custom listbox option<button role="tab" aria-selected={selected} css={` &[aria-selected='true'] { border-bottom: 2px solid currentColor; } `}> {label}</button>// Disabled control (alternative to :disabled that works on any element)<div role="button" aria-disabled={disabled} css={` &[aria-disabled='true'] { opacity: 0.5; pointer-events: none; } `}> {children}</div>Why this pattern works best:
- Single source of truth β attribute is both the reactive binding and style condition
- Accessible by default β ARIA attributes communicate state to assistive technologies
- Inspectable β attributes are visible in DevTools, debugging is trivial
- Testable β query by semantic selectors (
[aria-current],[disabled]) instead of class names
Components
Components are plain functions that return DOM elements. They are stateless, have no lifecycle, and are evaluated only once β at the moment of mounting.
Thereβs no virtual tree or diffing β everything happens directly in the DOM.
For small, rarely changing lists, store elements in an atom and map them inside reactive children (see below). For lists that grow, shrink, or reorder often, use reatomLinkedList β it applies structural changes incrementally instead of rebuilding the whole subtree on every edit.
const list = atom([<li>1</li>, <li>2</li>])
const add = () => list.set((state) => [...state, <li>{state.length + 1}</li>])
<div> <button on:click={add}>Add</button> {() => <ul>{list().map((item) => item)}</ul>}</div>β Do not reuse elements
JSX elements are real DOM nodes, not virtual descriptions. Reusing the same element instance multiple times leads to incorrect rendering β it will only appear in the last place it was inserted. Each JSX element must be created fresh where itβs used.
β Incorrect
const shared = <span>{valueAtom}</span>
<> <div>{shared}</div> <p>{shared}</p></>Result:
<div></div><p> <span>text</span></p>Only the last usage is rendered β the first one is silently dropped.
β Correct
const Shared = () => <span>{valueAtom}</span>
<> <div><Shared /></div> <p><Shared /></p></>Result:
<div><span>Hello</span></div><p><span>Hello</span></p>Each call creates a unique element with its own lifecycle and subscriptions.
Linked lists
Reactive array children are perfect for short, mostly-static lists. There is no virtual DOM and no keyed reconciliation here, so a {() => items().map(...)} child renders through a live fragment: whenever the array changes, the current children are torn down and the freshly mapped result is inserted in their place. For a handful of items that is exactly what you want β cheap and simple.
Once a list grows long, reorders often, or gets edited many times per interaction, that full rebuild starts to cost you. This is where reatomLinkedList from @reatom/core earns its place. It keeps every item as a stable node in a doubly linked list, and each structural edit is appended to a change log that bumps a version number. @reatom/jsx subscribes to that log and replays only what changed onto the DOM, so the list is patched in place rather than recreated from scratch.
flowchart LR A["linked list actions"]; B["changes[] + version"]; C["reatom/jsx subscription"]; D["direct DOM updates"] A --> B B --> C C --> DPerformance vs reactive arrays
| Classic immutable array change | Reactive array child | LL method | Linked-list child |
|---|---|---|---|
Add β arr.set(state => [...state, next]) | Rebuilds the live fragment | create / createMany | Appends nodes, batched in one DocumentFragment |
Remove β arr.set(state => state.filter(...)) | Rebuilds the live fragment | remove / removeMany | Drops the row node (or its live-fragment markers) |
Reorder β arr.set(state => [...state].sort()) | Rebuilds the live fragment | move / swap | Reuses the same DOM elements via native insertion APIs |
Update item β arr.set(state => state.with()) | Rebuilds the live fragment | inner atom .set() | Row node is reused; inner atoms and props update |
Performance aside, the linked-list methods are simply nicer to live with. create, remove, move, and swap say what you mean, while the immutable-array column above leans on index math, spreads, and filter/map/with callbacks β more code to write and more places to get an index wrong. So even for modest lists where rebuild cost is a non-issue, reach for reatomLinkedList when you want the friendlier API.
Data + view list
.reatomMap() keeps a parallel linked list of views, tied back to the source nodes through a WeakMap. Since that mapping is stable, move, swap, and remove on the source quietly reuse the matching view rows. The rule to remember: edit the source list, never the mapped view. For example:
import { atom, reatomLinkedList } from '@reatom/core'
const todos = reatomLinkedList((title: string) => atom(title), 'todos')
const TodoList = () => ( <ul> {todos.reatomMap((titleAtom) => ( <li> <input model:value={titleAtom} /> <button on:click={() => todos.remove(titleAtom)}>Remove</button> </li> ))} </ul>)Examples: drag-and-drop, gallery, reatomFieldArray for dynamic form rows.
API and when to use
Every structural method records a change: create, createMany, remove, removeMany, move(node, after) (pass after === null to insert at the head), swap, clear, and batch for folding many edits into a single DOM pass. You can also walk the list by hand through list.LL_PREV / list.LL_NEXT. When you just need a plain snapshot, reach for list.array() or deatomize(list.array) β great for reading, but not for mounting large dynamic lists. And when lookup by id matters, reatomLinkedList({ create, key: 'id' }) together with list.map() is comfortable for hundreds of items, though youβll want another approach once you reach the thousands.
| Scenario | Use |
|---|---|
| Tiny list, rare edits | Atom + {() => items().map(...)} |
| Feeds, drag-and-drop, tables, field arrays | reatomLinkedList + optional .reatomMap() |
| Lookup by id | reatomLinkedList({ create, key: 'id' }) + list.map() |
Caveats
- List nodes have to be objects or functions, so wrap primitives in objects, atoms, or DOM elements first.
- The same node cannot appear twice in one list.
- Native
DocumentFragmentrows are not supported. moveandswapare still rough on fragment rows, so prefer a single root element per row β or render element rows through.reatomMap()and reorder the source nodes instead.
$spread prop
Use $spread to declaratively bind multiple props or attributes at once. The object can be reactive (e.g., atom, () => obj) and will update automatically.
<div $spread={() => valid() ? { disabled: true, readonly: true } : { disabled: false, readonly: false }, }/>Always include all relevant keys on each update to avoid stale DOM state.
SVG
To create SVG elements, use the svg: namespace prefix to the tag name.
<svg:svg viewBox="0 0 24 24"> <svg:path d="..." /></svg:svg>SVG elements are rendered as native DOM nodes in the SVG namespace.
If you need to inject raw SVG markup, use one of the following approaches:
Option 1: parse from string
const SvgIcon = ({ svg }: { svg: string }) => new DOMParser() .parseFromString(svg, 'image/svg+xml') .children.item(0) as SVGElementOption 2: use prop:outerHTML
const SvgIcon = ({ svg }: { svg: string }) => <svg:svg prop:outerHTML={svg} />ref props
Use the ref prop to get access to the DOM element and register mount/unmount side effects.
<button ref={(el) => { el.focus() return (el) => el.blur() }}/>Unmount callbacks are called automatically in reverse order β from child to parent:
<div ref={() => { console.log('mount parent') return () => console.log('unmount parent') }}> <span ref={() => { console.log('mount child') return () => console.log('unmount child') }} /></div>Console output:
mount childmount parentunmount childunmount parentUtilities
reatomClassName class names reactivity
reatomClassName works similarly to clsx or classnames, but with full reactivity support β you can pass strings, arrays, objects, functions, and atoms. All values are automatically converted into a class string that updates when dependencies change.
- Strings are added directly.
- Arrays are flattened and processed recursively.
- Objects add a key as a class if its value is truthy.
- Functions and atoms are tracked reactively and automatically recomputed on changes.
reatomClassName('my-class') // Computed<'my-class'>
reatomClassName(['first', atom('second')]) // Computed<'first second'>
/** * The `active` class will be determined by the truthiness of the data property * `isActiveAtom`. */reatomClassName({ active: isActiveAtom }) // Computed<'active' | ''>
reatomClassName(() => (isActiveAtom() ? 'active' : undefined)) // Computed<'active' | ''>The reatomClassName function supports various complex data combinations, making it easier to declaratively describe classes for complex UI components.
/** * @example * Computed<'button button--size-medium button--theme-primary button--is-active'> */reatomClassName([ 'button', `button--size-${props.size}`, `button--theme-${props.theme}`, { 'button--is-disabled': props.isDisabled, 'button--is-active': props.isActive() && !props.isDisabled(), },])css template literal
You can import css function from @reatom/jsx to describe separate css-in-js styles with syntax highlight and Prettier support. Also, this function skips all falsy values, except 0.
import { css } from '@reatom/jsx'
const styles = css` color: red; background: blue; ${somePredicate && 'border: 0;'}`You can use this with the
cssorstyleprops.
<Bind> component
You can use <Bind> component to use all @reatom/jsx features on top of existed element. For example, there are some library, which creates an element and returns it to you and you want to add some reactive properties to it.
import { Bind } from '@reatom/jsx'
const MyComponent = () => { const container = new SomeLibrary()
return ( <Bind element={container} class={() => (visible() ? 'active' : 'disabled')} /> )}TypeScript
JSX components in @reatom/jsx are plain functions and integrate seamlessly with TypeScript.
Typing component props
If you want to define props for a specific HTML element you should use it name in the type name, like in the code below.
import { type JSX } from '@reatom/jsx'
// allow only plain data typesinterface InputProps extends JSX.InputHTMLAttributes { defaultValue?: string}// allow plain data types and atomstype InputProps = JSX.IntrinsicElements['input'] & { defaultValue?: string}
const Input = ({ defaultValue, ...props }: InputProps) => { props.value ??= defaultValue return <input {...props} />}Use
JSX.IntrinsicElements['tagName']to get the correct typing for a given element (input,button,div, etc).
Typing event handlers
You can annotate event handlers explicitly with built-in types.
const Form = () => { const handleSubmit = (event: Event) => { event.preventDefault() }
const handleInput = (event: JSX.InputEvent) => { const value: number = event.currentTarget.valueAsNumber // e.g. valueAtom.set(value) }
const handleSelect = (event: JSX.TargetedEvent<HTMLSelectElement>) => { const value: string = event.currentTarget.value // e.g. selectAtom.set(value) }
return ( <form on:submit={handleSubmit}> <input on:input={handleInput} /> <select on:input={handleSelect} /> </form> )}Extending JSX typings
You may have custom elements that youβd like to use in JSX, or you may wish to add additional attributes to all HTML elements to work with a particular library. To do this, you will need to extend the IntrinsicElements or HTMLAttributes interfaces, respectively, so that TypeScript is aware and can provide correct type information.
Add new intrinsic elements
function MyComponent() { return <loading-bar showing /> // ~~~~~~~~~~~ // π₯ Property 'loading-bar' does not exist...}To fix:
declare global { namespace JSX { interface IntrinsicElements { 'loading-bar': { showing?: boolean | null | undefined } } }}
// This empty export is important! It tells TS to treat this as a moduleexport {}Add custom HTML attributes
function MyComponent() { return <div custom="value" /> // ~~~~~~ // π₯ Property 'custom' does not exist...}To fix:
declare global { namespace JSX { interface HTMLAttributes { custom?: string | null | undefined } }}
// This empty export is important! It tells TS to treat this as a moduleexport {}Donβt forget the
export {}at the bottom β it makes the file a module so TypeScript merges types correctly.
Limitations
These features are not yet supported:
- β DOM-less SSR (you need a DOM-like environment such as linkedom)
- β React-style keyed reconciliation (use
reatomLinkedListβ node identity is the key; structural updates are incremental)