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, useRef | unchanged |
useMemo, useCallback, custom hooks | unchanged |
useImperativeHandle, forwardRef, ref as a prop, ref cleanup | unchanged |
use(promise), use(context), React.lazy | unchanged |
useOptimistic, useActionState, <Profiler> | unchanged |
useSyncExternalStore | unchanged, and the best way to bridge non-React code |
useTransition, startTransition, useDeferredValue | work, and really do stay responsive |
useLayoutEffect vs useEffect | the 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 boundaries | work — but where you put one decides if the window survives |
<StrictMode> | safe; effects are not double-invoked on the first mount |
useId | works; there is very little here to use it for |
createPortal | use <popup> instead — see Portals |
flushSync | not exported; you almost certainly do not need it |
useFormStatus | not wired up |
| React Server Components | not available |
anything imported from react-dom | not 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 from | what the user sees |
|---|---|
useLayoutEffect | one frame, with the corrected value |
useEffect | one 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.
onLayoutfires on any element after a layout pass moved or resized it, withwidth/height(andx/ywithin 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
absfrom 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)anduseAnchor()return getters for exactly this reason — a value captured during render would benullon 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 whatwindowIdOf()resolves to an XID. Both spellgetContext('2d')andrequestAnimationFramethe 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(), andabsfor 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 instartTransitiondoes nothing.- A
startTransitioninside 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 asref.current.root— or better, drive repaints through props andcacheKey.