Skip to main content

React features

You already know React. This page is about the parts where that knowledge transfers unchanged, the parts where it transfers with a caveat, and the four places where a habit from the DOM will quietly not work.

The short version: everything in React core behaves exactly as documented. Hooks, context, error boundaries, <Suspense>, lazy, use, transitions, <Profiler>, <StrictMode> — all of it. react-x11 is a normal React 19 renderer, and where React's behaviour does not depend on the DOM, it is the same behaviour.

Everything on this page is also something you can drive by hand — in the apps that depend on it rather than in a panel of its own:

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

Those replaced an examples/react-features.jsx that had a panel per feature. A panel proves the API exists; it cannot show you the choice, because nothing in it costs anything. monitor's field is expensive because a process list is expensive, and that is the only condition under which deferring it is a decision rather than a demonstration.

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, useMemo, useCallback, useRefunchanged
useImperativeHandle, forwardRef, ref as a prop, ref cleanupunchanged
use(promise), use(context), React.lazyunchanged
useOptimistic, useActionState, <Profiler>, custom hooksunchanged
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; read Suspense and Activity for <window>
<Activity>works; mode="hidden" around a <window> unmaps it
Error boundarieswork — but where you put one decides if the window survives
<StrictMode>safe to use; 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. This is not a timing race you can get lucky with — updates from a layout effect are folded into the frame being prepared, and updates from a passive effect are not.

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

// good — the corrected size is in the first frame the user sees
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. This is the DOM habit most likely to break on the way over, because in a browser getBoundingClientRect() both reads and triggers.

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. <box onViewport> fires when the viewport or content size changes, with width/height/contentWidth/ contentHeight. <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
style={{ overflow: 'scroll' }}
onViewport={({ width }) => setCols(Math.max(1, (width / 200) | 0))}
>
{items.map()}
</box>;

If you are porting a component that measures itself and adjusts, this is the part to rewrite. See elements.md for 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 ntk window — the object with getContext('2d'), and what windowIdOf() resolves to an XID.
  • 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.

What is worth knowing is that transitions are the 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, wrapping it in startTransition (or deferring its input with useDeferredValue) is not a micro-optimisation, it 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.

react-x11 already classifies X11 input for you: presses, releases, keys, focus changes, window close and drops are urgent; pointer motion and drag-over are not, which is what keeps a motion burst from flooding the renderer. You do not have to do anything to get this.

examples/monitor.jsx is this paragraph with a keyboard on it: a filter over hundreds of live rows, where one keystroke changes every row on screen. Without the deferral the caret waits for the table; with it the field stays under the fingers and the rows arrive a beat later. Two things there are worth copying, and both are one-line mistakes in the other direction: 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. It is also faster here than a hand-rolled setState bridge: a store notification is applied urgently and lands in the next microtask, where the equivalent setState waits a turn longer.

Every mainstream store (zustand, jotai, valtio, redux, XState) works with no adapter for this reason. 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. The one thing to plan for is layout: a hidden subtree gives up its space entirely, so the fallback lays out as if the real content were not there. 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.

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> reads the same way <Suspense> does: the window is unmapped, and one mounted hidden is never mapped at all — it is created, laid out and kept, but the window manager only ever hears about it on 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>. It is what you want in almost every case where the DOM would reach for createPortal: a <popup> is written as an ordinary child in your JSX — so it sees context, state and props exactly where you wrote it — but it is a real top-level X window, so it escapes its parent's clipping and can extend past the window edge.

<box>
<Button
onClick={(e) => setAt({ x: e.nativeEvent.rootx, y: e.nativeEvent.rooty })}
>
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 X11-specific decision 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, through the supported react-x11/refresh entry point (node --import react-x11/refresh/register). 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.

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 is not the import error — it is that 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 is talking 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.