Skip to main content

React features

Everything in React core behaves as documented — hooks, context, error boundaries, <Suspense>, lazy, use, transitions, <Profiler>, <StrictMode>. react-x11 is a normal React 19 renderer, and where React's behaviour does not depend on the DOM, it is the same behaviour. This page is the caveats, and the four places where a DOM habit quietly does not work.

The examples that exercise it:

npm run examples:monitor # priority: a filter over ~780 live rows
npm run examples:chat # <Activity>, useOptimistic, <Suspense>
npm run examples:timer # error boundaries, and where a crash is logged

What differs is anything the DOM used to define for you:

  • Paint timing. A commit does not put pixels on screen. Frames go out on a clock, so "before paint" is a real, observable boundary.
  • Measuring. There is no getBoundingClientRect() that forces layout. Layout has already happened, or it has not.
  • Hiding. <Suspense> and <Activity> hide a <box> cheaply, but hiding a <window> genuinely unmaps it, and the window manager notices.
  • Portal containers. There is no document to portal into.

At a glance​

useState, useReducer, useContext, useRefunchanged
useMemo, useCallback, custom hooksunchanged
useImperativeHandle, forwardRef, ref as a prop, ref cleanupunchanged
use(promise), use(context), React.lazyunchanged
useOptimistic, useActionState, <Profiler>unchanged
useSyncExternalStoreunchanged, and the best way to bridge non-React code
useTransition, startTransition, useDeferredValuework, and really do stay responsive
useLayoutEffect vs useEffectthe difference is real — one paint versus two
<Suspense>works; around a <window> it unmaps it
<Activity>works; mode="hidden" around a <window> unmaps it
Error boundarieswork — but where you put one decides if the window survives
<StrictMode>safe; effects are not double-invoked on the first mount
useIdworks; there is very little here to use it for
createPortaluse <popup> instead — see Portals
flushSyncnot exported; you almost certainly do not need it
useFormStatusnot wired up
React Server Componentsnot available
anything imported from react-domnot available — see Not available

Effects, and the paint boundary​

Put an effect that changes what the user sees in useLayoutEffect. Put everything else in useEffect. That is the same rule as the DOM, and here it has a directly observable consequence:

you call setState fromwhat the user sees
useLayoutEffectone frame, with the corrected value
useEffectone frame with the old value, then a frame with the new

So an adjustment made in useEffect flickers, every time: updates from a layout effect are folded into the frame being prepared, and updates from a passive effect are not.

That holds for a correction computed from React state. One computed from geometry is a layout behind: a layout effect runs after the commit and before the layout pass, so abs there is still the previous frame's rect (zeros, on mount). For a size, let the element report it with onLayout (below); for a style that depends on one, write a container query (styling.md), which the pass answers in the same frame. scrollIntoView and scrollTo — including scrollTo(Infinity) to follow rows this commit added — are safe here: the pass answers them against the geometry it produces (elements.md).

The same boundary is why an event handler feels instant: the React update caused by a click or a keystroke lands before the frame goes out, so the response and the default action paint together.

// good — `tooTall` and `fits` are React state, so the corrected rows paint
// in the same frame as the state that asked for them
useLayoutEffect(() => {
if (tooTall) setRows(fits);
}, [tooTall, fits]);

Measuring a node​

There is no forced synchronous layout. Nothing you can call will make layout run so you can read the result, as getBoundingClientRect() does in a browser.

A node's abs rect (and getClientRects()) reports what the last layout produced. On the very first render of a node, that is nothing yet.

What to do instead:

  • Let the element tell you. onLayout fires on any element after a layout pass moved or resized it, with width/height (and x/y within the parent) in logical pixels — once after the first layout, then on change. <box onViewport> adds a scroller's content size, and <window onResize> fires when the window is resized. These are the supported way to react to a size.
  • Read abs from a ref in a handler or a later effect, not during the render that created the node.
  • Take a getter, not a value. useWindowId(ref) and useAnchor() return getters for exactly this reason — a value captured during render would be null on the render where you needed it.
const [cols, setCols] = useState(1);

<box onLayout={({ width }) => setCols(Math.max(1, (width / 200) | 0))}>
{items.map(…)}
</box>;

When what depends on the size is a style rather than a decision about what to render, write it as a container query block instead (styling.md): it applies in the same frame, where a state set from onLayout re-renders a frame later.

If you are porting a component that measures itself and adjusts, this is the part to rewrite. See elements.md for onLayout, onViewport and onResize, and components.md for useAnchor.

What a ref gives you​

Two different kinds of object, depending on the element:

  • <window> and <popup> hand back the live window object of whichever backend is drawing — on X11 the ntk window, which is what windowIdOf() resolves to an XID. Both spell getContext('2d') and requestAnimationFrame the same way; anything past that is the backend's own surface, so a component that reaches further should say which backend it is for.
  • every other element hands back its node: focus(), blur(), focused, hitTest(), containsPoint(), getClientRects(), and abs for its position and size. <textinput>/<textarea> additionally match the DOM closely enough that libraries like react-hook-form drive them through a ref.

A window ref is null until that window exists, and refs attach after the commit — so read them in effects and handlers, never during render.

For <canvas>, note that the node is not a drawing surface. There is no getContext() on it; you draw in onDraw, which the renderer calls with a context whenever the canvas needs repainting:

<canvas onDraw={(ctx, { width, height }) => { …}} />

To make it repaint, change something React can see — a prop, state, or the cacheKey. That is the supported path, and it is usually what you want. There is an imperative escape hatch for element authors in extending.md, but note that it lives on the owning window, not on the drawn node, despite what the bundled types currently say (see Known gaps).

Transitions, and what stays responsive​

useTransition, startTransition and useDeferredValue all work, and they really do keep the UI responsive — React will interrupt a transition render to handle an incoming click or keystroke.

Transitions are the only mechanism that does this. An ordinary setState — from a handler, a timer, a socket callback — renders in one uninterruptible pass, however much work it is. If a render is big enough to drop a frame, startTransition (or useDeferredValue on its input) is the difference between a responsive window and a frozen one.

const [pending, startTransition] = useTransition();

// typing stays instant; the expensive list catches up
const onChange = (e) => {
setQuery(e.value);
startTransition(() => setResults(search(e.value)));
};

Two smaller notes:

  • root.render() is synchronous — the tree is committed before the call returns. Wrapping it in startTransition does nothing.
  • A startTransition inside a click handler is still a transition. It does not inherit the handler's urgency.

Input is already classified for you: presses, releases, keys, focus changes, window close and drops are urgent; pointer motion and drag-over are not, which keeps a motion burst from flooding the renderer.

examples/monitor.jsx is the worked case: a filter over hundreds of live rows, where one keystroke changes every row on screen. Two things there are worth copying: the expensive subtree is memoised (or the urgent render rebuilds it anyway, and deferring buys nothing), and no prop that changes on every update — not even a "stale" border — is passed into it.

Bridging non-React code​

useSyncExternalStore is the right tool for anything that lives outside React — a timer, a D-Bus signal, a socket, an ntk event — and faster than a hand-rolled setState bridge: a store notification is applied urgently, in the next microtask, where the equivalent setState waits a turn longer. Every mainstream store (zustand, jotai, valtio, redux, XState) works with no adapter. The window-manager example is the worked case: the WM core is a plain store, and the UI subscribes to it.

Suspense and Activity​

<Suspense> works. Plan for layout: a hidden subtree gives up its space entirely, so size your fallback deliberately, or the window collapses to the fallback and jumps back on reveal.

<Suspense fallback={<box style={{ height: 240 }} />}>
<Report />
</Suspense>

Focus goes with the hidden tree and comes back with it. A field inside the boundary gives up the keyboard when the fallback appears — nothing invisible collects keystrokes — and gets it back on the reveal unless something else took focus meanwhile, so a boundary that re-suspends mid-edit does not drop the user out of what they were typing. See events.md for the rules and the way out.

Scroll positions come back too. A hidden boundary's content is not laid out while hidden (as under display: 'none'), so a scroll pane in it is not clamped, reports no onViewport or onScroll until shown, and reappears at the offset it was left at — a tab kept under <Activity> reopens on the row the user was reading.

If a <window> is inside the boundary, hiding it unmaps the real window. The window manager treats the reveal as a new window: it may re-place it, restack it, or apply its own geometry, and anything the user did to it can be lost. Prefer suspending inside a window over suspending the window itself.

React.lazy works. If you ship a single-file bundle, note that dynamic import() gets inlined — you keep lazy evaluation but get no code splitting; see packaging.md.

<Activity> works, and mode="hidden" around a toplevel <window> unmaps it the same way; one mounted hidden is created and laid out but never mapped until the reveal. The same caveat applies: what the user did to the window before it was hidden is the window manager's to remember, and it may not.

Testing Suspense: a promise that settles outside act() is not always picked up by an await act(), because React throttles the commit when a fallback was shown very recently. Use waitFor from react-x11/test — see testing.md.

Portals​

Use <popup> where the DOM would reach for createPortal. A <popup> is an ordinary child in your JSX — it sees context, state and props where you wrote it — but a real top-level window, so it escapes its parent's clipping and can extend past the window edge.

<box>
<Button onClick={(e) => setAt({ x: e.screenX, y: e.screenY })}>
Options…
</Button>
{at && (
<popup
x={at.x}
y={at.y}
width={180}
height={120}
grab
onDismiss={() => setAt(null)}
>
<Menu />
</popup>
)}
</box>

Its position in the JSX has no effect on where it appears — you place it in screen coordinates, and useAnchor() does that math for you when you are anchoring to another node.

Dialog, Tooltip, ContextMenu, MenuBar and Select are all built this way — see components.md.

There is no createPortal export. The reconciler's own is reachable through the Renderer escape hatch, but it is not a supported surface: the container has to be an X connection or an X window rather than a node, and only <window>/<popup> can be the portal's immediate child. If you find yourself wanting it, you almost certainly want a second <window> or a <popup>.

Where to put an error boundary​

Boundaries work exactly as documented. The decision specific to a desktop renderer is placement, and it decides whether the user's window survives:

// good — the window and everything the WM knows about it survives
<window title="Editor">
<ErrorBoundary fallback={<text>Something broke</text>}>
<Document />
</ErrorBoundary>
</window>

// the window itself is destroyed and recreated on a throw
<ErrorBoundary fallback={<window title="Editor">…</window>}>
<window title="Editor">
<Document />
</window>
</ErrorBoundary>

A recreated window is a new window: its position, size, stacking, maximized state and anything else the user or the window manager did to it are gone. Put the boundary inside the window unless you genuinely want the window replaced.

An uncaught error unmounts the whole tree, which here means every window disappears while the process stays alive. createRoot takes onUncaughtError, onCaughtError and onRecoverableError so you can decide what that should mean for your app — the default logs and sets a failing exit code. See events.md.

One thing React does not cover: a throw inside an event handler never reaches a boundary. (That is true in React DOM too.) react-x11 routes those to onUncaughtError instead, so they are at least reportable rather than silent.

Smaller notes​

useId works, but there is little use for it: no element takes an id, there is no hydration, and labels are associated structurally (<Checkbox label>, ``children</Radio>). If you want a window's real identity, that is useWindowId(). Avoid putting an id prop on <window> — unknown props there are forwarded to the underlying window as creation attributes.

<StrictMode> is safe. A discarded render costs no X11 traffic, so double-rendering never produces double windows. Be aware that effects are not double-invoked on the initial mount, so it will not catch a missing cleanup at mount the way it does in React DOM.

memo / useMemo save CPU, not bandwidth. Rebuilding a style object or array every render is free — styles are compared by value, so an identical style produces no layout work, no repaint and no frame. Passing style={[base, active && on]} inline is the documented idiom, not a leak. See styling.md.

Keys matter more for <window> children than for drawn ones. Reordering <box> children is cheap. Reordering sibling <window>s restacks real windows, and a missing key destroys and recreates them — with the same loss of window-manager state described above. To keep a window on top, use alwaysOnTop rather than ordering.

DevTools works — set REACT_X11_DEVTOOLS=1 and the component tree, props and highlight-on-hover all behave normally, with the highlight drawn into the real window. The Profiler Timeline tab is unavailable; the commit list and flamegraph work. See devtools.md.

Fast Refresh works without a bundler: npx react-x11 dev app.jsx runs a file with its default export mounted and every save applied in place, over the react-x11/refresh entry point, which a tool that hosts the loader itself registers with --import. Keep anything whose identity must survive a reload — contexts, stores — in a module you are not editing. See dev tooling and the hot-reload example, which is npm run examples:tasks:hot.

Not available​

react-dom. It is not a dependency, so ReactDOM.createPortal, flushSync, findDOMNode, hydrateRoot and unstable_batchedUpdates are not part of react-x11.

The trap: react-dom is a required peer of some libraries, so it can end up installed anyway. Then the import resolves, and flushSync() runs your callback without flushing anything, because it talks to a renderer you are not using. If a library depends on synchronous flushing, check npm ls react-dom; the ecosystem register records which packages hit this.

Server Components are not available: there is no Flight client wired up.

useFormStatus is not wired up. useOptimistic and useActionState are unaffected and work normally.

Known gaps​

Open bugs where a React feature does not yet mean here what it should:

  • A <popup> written inside a subtree that <Suspense> or <Activity> hides stays on screen: React hides the topmost host instance of the branch and the popup is its own X window below it, so nothing unmaps it. Focus follows what is actually visible, so the popup keeps the keyboard — but the popup itself has to be conditional in your JSX rather than left to the boundary.
  • The bundled types declare invalidate() on every node; at runtime only the owning <window> has it. Reach it as ref.current.root — or better, drive repaints through props and cacheKey.