React & JSX
The thing most lightweight frameworks give up is the React ecosystem. JAF
does not: drop a .tsx or .jsx file into your
components directory - written by you, or wrapping a React library - and use
it like any other JAF component. It is auto-discovered by filename, takes
props from your templates, and renders into the same tree.
<Card title="Report">Contents</Card> reads the same
whether Card is a .html file or React code behind
the scenes.
What it is not is a React island. JAF compiles those files with JetShake, a Svelte/Solid-style optimising compiler for React: standard JSX and hooks in, signals and direct DOM operations out.
There is no React runtime in your bundle, and react is not a
dependency of your project.
Setup
// vite.config.ts - jetshakePlugin is part of the default JAF plugin set
import { jafPlugin, fileRouterPlugin, jafComponentsPlugin,
jafExpressionPlugin, jetshakePlugin } from 'jafjs/vite'
export default defineConfig({
plugins: [
jafExpressionPlugin(),
jetshakePlugin(),
jafPlugin(),
fileRouterPlugin(),
jafComponentsPlugin(),
]
})
// Nothing else to install. Note what is NOT here:
// react, react-dom, @vitejs/plugin-react.
Nothing to install. The compiler ships with JAF, and
jetshakePlugin() is part of the plugin set
npm create jaf scaffolds for you.
When there is at least one .tsx or .jsx file in your
project, the plugin compiles it and points the react and
react-dom specifiers at JetShake's own React-compatible surface.
Nothing resolves to the real React packages, because they are never installed.
It costs nothing until you use it. With no JSX files in the project the plugin loads no compiler, contributes no aliases, and ships no bytes. The runtime reaches the browser only inside the chunks your JSX components create, which are loaded on demand.
On this site, the first JSX component cost 8.14 kB gzip for runtime plus
component. The same counter on real react and
react-dom/client is about 58.9 kB.
Writing One
Standard React. Hooks, JSX, TypeScript:
// components/Greeting.tsx
import { useState } from 'react'
export default function Greeting(props: { name?: string }) {
const [waves, setWaves] = useState(0)
return (
<div>
<p>Hello, {props.name}!</p>
<button onClick={() => setWaves(waves + 1)}>wave ({waves})</button>
</div>
)
}
Export it as default, or under a name matching the filename.
There is no import and no registration: the same scanner that finds your
.html components finds this one, and the tag is the filename.
<!-- Any JAF template. No import, no registration. -->
<Greeting name="Ada" />
<!-- Expression props work exactly as they do on a JAF component -->
<script>
let who = "Ada"
</script>
<Greeting name="{who}" />
A Real One, Running
Below is a real .tsx file mounted on this page.
The label comes from a JAF signal in the surrounding template, the count is
the component's own useState, and the readout is JAF receiving a
callback from it.
from JAF: hello
JAF saw the count go to 0
This is the whole component. Nothing is elided:
// components/JsxCounter.tsx - the whole file, as it runs above
import { useState } from 'react'
interface Props {
label?: string
step?: number
onCount?: (next: number) => void
}
export default function JsxCounter(props: Props) {
const [count, setCount] = useState(0)
return (
<div className="jsx-counter">
<p className="jsx-counter-label">from JAF: <strong>{props.label}</strong></p>
<div className="jsx-counter-row">
<button type="button" className="jsx-counter-button" onClick={() => {
const next = count + (props.step ?? 1)
setCount(next)
if (props.onCount) props.onCount(next)
}}
>
+{props.step ?? 1}
</button>
<span className="jsx-counter-value">{count}</span>
</div>
</div>
)
}
And this is the JAF page using it, tag and all:
<script>
let greeting = "hello"
let step = 1
let lastCount = 0
function swap() {
greeting = greeting === "hello" ? "goodbye" : "hello"
}
function bumpStep() {
step = step === 1 ? 5 : 1
}
function record(next) {
lastCount = next
}
</script>
<div class="jsx-demo">
<JsxCounter label="{greeting}" step="{step}" onCount="{record}" />
<div class="jsx-demo-controls">
<button onclick="swap()">change label</button>
<button onclick="bumpStep()">step: {step}</button>
</div>
<p class="jsx-demo-readout">JAF saw the count go to <strong>{lastCount}</strong></p>
</div>
Press change label and watch the count survive. That is the point: the prop change updated one text node, it did not re-mount the component.
Props
JAF passes props to a JSX component as lazy getters backed by signals.
When the parent's reactive state changes, the component follows, and it keeps its own state while it does.
How closely it follows depends on which signature you write:
// Fine-grained. `props.count` is read inside the component's own effects, so
// a change updates that one text node and nothing else runs again.
export default function Total(props: { count?: number }) {
return <p>Total: {props.count}</p>
}
// Also live, more coarsely. A destructured prop is read in the body, and the
// body runs inside the effect JAF mounts the component in, so a change
// re-invokes the whole component and swaps its nodes. useState survives.
export default function Total({ count }: { count?: number }) {
return <p>Total: {count}</p>
}
Both are live. props.count is read inside the component's own
effects, so a change touches one text node.
A destructured prop is read in the component body, and the body runs inside
the effect JAF mounts it in, so a change re-invokes the component and swaps
its nodes. Same with {...rest} spread, and same with a component
that falls back to the compat renderer.
Here is the destructured signature, live:
from JAF: hello
The label follows the JAF signal, and the count the component owns survives.
// components/JsxDestructured.tsx - the component running above
import { useState } from 'react'
interface Props {
label?: string
}
export default function JsxDestructured({ label }: Props) {
const [kept, setKept] = useState(0)
return (
<div className="jsx-counter">
<p className="jsx-counter-label">from JAF: <strong>{label}</strong></p>
<div className="jsx-counter-row">
<button type="button" className="jsx-counter-button" onClick={() => setKept(kept + 1)}
>
+1
</button>
<span className="jsx-counter-value">{kept}</span>
</div>
</div>
)
}
Click +1 a few times, then change label. The label updates
and the count stays where you left it, because JAF mounts the component under
a stable identity and useState is keyed to it.
Prefer props.x anyway. Destructuring costs
you a re-invocation of the whole component on every prop change, which is
React's granularity, not JAF's. Reading off the props object is the same
code with one fewer thing happening.
Expression props keep their JavaScript type. A prop written as an expression hands the component a real number, array, object or function. Only quoted literal attributes arrive as strings, exactly as they would in HTML.
A valueless attribute follows the JSX convention rather than the HTML
one: <Widget flag /> arrives as true, not
"".
Talking Back to JAF
A function prop is just a prop. Pass a JAF function in, call it from the component:
// components/Rating.tsx
export default function Rating(props: { onRate?: (stars: number) => void }) {
return (
<div>
{[1, 2, 3, 4, 5].map((n) => (
<button key={n} onClick={() => props.onRate?.(n)}>{n}</button>
))}
</div>
)
}
<script>
let stars = 0
function handleRate(n) {
stars = n
}
</script>
<Rating onRate="{handleRate}" />
<p>You rated it {stars}.</p>
The function crosses by identity, and keeps that identity while unrelated parent state changes around it.
Replace the function in the parent and the next call uses the new one.
Children
Writing content inside a .tsx tag from a JAF template is a
build error. An island takes props, not projected JAF markup:
children reaches a React component as a value the mount builds,
and what a JAF parent compiles its own markup into is a render function, which
is not that value.
<!-- WRONG: a build error -->
<Panel><p>Body</p></Panel>
<!-- RIGHT: the island renders its own content, from props -->
<Panel body="Body" />
Inside the island, props.children is ordinary React and works as
it always has - it is the JAF-to-JSX boundary that carries props only.
Styling
A .tsx component's markup is produced at runtime by compiled
JavaScript, so it carries none of JAF's scoping classes. A scoped
<style> block in the parent template cannot reach inside
it.
Style it the way you would style any React component: a stylesheet the component imports, plain global CSS, or inline styles.
Cleanup
Every effect the component creates is captured at mount and disposed when the component unmounts, whether that is a conditional going false or a navigation away.
You do not have to do anything for this.
What Falls Back
Some components cannot be lowered to signals. Those keep working: JetShake
ships a React-19-compatible renderer and falls back to it automatically, one
component at a time. It is part of the jetshake package, so this
still needs no react dependency. But it is a reconciler, so
those components get coarse-grained updates and add weight to the bundle.
The classes that fall back today:
- Class components - anything extending
React.Component - Render props and children-as-a-function
React.lazy()- Suspense boundaries and portals
- Components rendering
<html>or<body>
The build names every component that fell back and the reasons why, so the cost is visible rather than guessed at. It is a real cost: the reconciler is tens of kilobytes gzipped, against roughly one for a lowered component. It is code-split, though, so a page that uses only lowerable components never downloads it.
When to Use It
- You have existing React components to bring across.
- You are migrating off React gradually and want those components to stop costing you a runtime on day one.
- You want one file to serve both audiences. It is ordinary React source, so the same component can be published to npm for React consumers. There is no pipeline the other way.
-
You want typed props today. A
.tsxcomponent has a real props interface, where a.htmlcomponent's props arrive as attributes.
Limits
-
bind:is a build error. Two-way component binding syncs two JAF states by name, and a JSX component has no JAF state to sync with, sobind:label={title}stops the build naming the tag. Pass the value in as an ordinary prop and take the change back out through a callback. - No named slots, and no scoped styles reaching in. See above.
- Third-party React libraries are not guaranteed. A library that only uses hooks and JSX often compiles; one that reaches into React internals falls back to the compat renderer; one that needs the real React package does not work at all.
-
State is not shared with JAF automatically. Props flow in,
callbacks flow out. For anything wider, use
unite()and pass the values in as props. - JetShake is pre-1.0. Output shapes and bail-out boundaries are still moving.
A plain component is still simpler. For an ordinary
component, a .html file is less machinery than a
.tsx file, and it costs no JetShake runtime at all. Reach for
JSX when one of the reasons above applies, not by default.