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

source
// 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:

source
// 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.

source
<!-- 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.

output

from JAF: hello

0

JAF saw the count go to 0

This is the whole component. Nothing is elided:

source
// 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:

source
<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:

source
// 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:

output

from JAF: hello

0

The label follows the JAF signal, and the count the component owns survives.

source
// 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:

source
// 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>
  )
}
source
<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.

source
<!-- 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 .tsx component has a real props interface, where a .html component'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, so bind: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.