# React `@standarx/nav/react` is the same instance-per-tree system with a provider around it. The engines are deliberately not imported by the adapter, so an application that never mentions a gamepad pays no bytes for one — you build the plugins and pass them in. ```tsx import { useMemo, useRef, useState } from "react"; import { NavProvider, useIntent } from "@standarx/nav/react"; import { gamepadPlugin } from "@standarx/nav/gamepad"; import { spatialPlugin } from "@standarx/nav/spatial"; export function App() { const [open, setOpen] = useState(true); const plugins = useMemo(() => [gamepadPlugin(), spatialPlugin({ mode: "app" })], []); return ( {open ? setOpen(false)} /> : null} ); } function Dialog({ onClose }: { onClose: () => void }) { const surface = useRef(null); useIntent( (event) => { if (event.intent !== "back") return false; onClose(); return true; }, { trapped: true, within: surface }, ); return (
); } function Level() { const group = useRef(null); const [level, setLevel] = useState(0); useIntent( (event) => { if (event.intent !== "moveDown" && event.intent !== "moveUp") return false; const step = event.intent === "moveDown" ? 1 : -1; setLevel((at) => Math.max(0, Math.min(2, at + step))); return true; }, { within: group }, ); return
…
; } ``` **A trap names its surface, and a composite inside it names its own element.** `Level` mounts in the same commit as `Dialog`, and React runs a child's effect before its parent's, so the radio group's scope is opened first and sits *under* the dialog's trap. A trap silences what is beneath it, and without `within` the dialog silenced its own radio group ([issue #14](https://github.com/StandarX-miralabs-tech/standarnav/issues/14)). With `within` on both, a scope beneath the trap whose element lies inside the trap's surface is still asked — after the trap, because containment does not reorder the stack, so the dialog claims only what it owns (`back` here) and lets the arrows through. A trap or a scope with no `within` behaves exactly as before; the reasoning is [ADR-0025](../adr/0025-trap-within-its-surface.md). `within` takes a ref, an element or a getter, read at every dispatch, so a ref filled after the first commit or a new arrow on every render never re-opens the scope. `data-snav-trap` is still what keeps the spatial engine inside the dialog. Pinned in `src/react/react.browser.test.tsx` by "moves a radio group mounted with its dialog, its within given as ref" (and as getter, and as element), "keeps a radio group that names no element silenced, as before" and "does not open the scope again for a within that is a new arrow on every render"; `bun run test:browser` passed them on chromium, firefox and webkit on 2026-09-23. **The `useMemo` is the contract, not decoration.** The provider compares the `plugins` list element by element with `Object.is`, so a fresh array literal around stable instances costs nothing — but a plugin *constructed* in the JSX, `plugins={[gamepadPlugin()]}`, is a new object on every render: the contents genuinely did change, and the system is destroyed and rebuilt, modality refcount and all. Hoist the instances or memoise them. Two browser tests pin both halves. A `keymap` literal needs no such care — it is compared one level deep over its string values, so `keymap={{ keys: { … } }}` really is stable. | Export | What it is | |---|---| | `NavProvider` | Builds one input system for the tree and destroys it on unmount. | | `useIntent(handler, options?)` | Opens an intent scope for the component's lifetime. The handler is read through a ref, so an inline arrow does not pop and re-push the scope — which would silently reorder it under anything pushed since. A new `trapped` or `base` keeps the scope where it was opened. `within` — a ref, an element or a getter — is read at dispatch and never re-opens it. The handler's answer — `true`, `false`, nothing or `"native"` — reaches the bus unchanged. | | `useInputSystem()` | The system, or `null`. | | `useIntentScopeHost()` | A host stable for the life of the provider, for a state machine that installs its effects on entering a state and has no dependency array to re-run on. A scope pushed through it before the system exists is opened once it does. Its `pushScope` forwards the options as given, so `within` there is an element or a getter such as `() => ref.current`, not a ref. `null` without a provider. | | `useInputModality()` | `keyboard` \| `pointer` \| `touch` \| `gamepad`. Works with no provider above it: the modality store is ref-counted per document, so a component that only wants to know whether to draw a ring pays for a tracker, not for an input system. | | `NavDocumentProvider` | Only needed when the tree does not live in the page's own document — an iframe, a popup, a test fixture. | `useInputSystem()` answers `null` until the provider's effect has run, and `null` is also what it answers on the server and without a provider: `createInputSystem` needs a document and installs capture-phase listeners, so it cannot happen during render, and children render once with `null`. `useIntent` and `useIntentScopeHost` handle that themselves: a scope opened before the system exists is recorded by the provider and pushed when the system is built. `useIntent` warns in development only when there is genuinely no provider above it. **Scopes keep the order they were opened in, across a rebuild too.** A new `plugins`, `keymap`, `allowVerticalInText` or document makes the provider destroy its system and build another. Every scope opened through `useIntent` or `useIntentScopeHost().pushScope` is registered with the provider, in the order it was opened, and the provider re-opens all of them on the new system in that order, above the scopes its plugins push, before any component sees the new system (`NavProvider`'s effect, `src/react/react.tsx:188-190`). The components do not re-push anything themselves: their effects would run in tree order, children before parents, and a trap opened last could come back beneath the scope it was covering. A new `trapped` or `base` on `useIntent` keeps the scope in its place too — it is re-opened there, with every scope opened after it re-opened above it again. Each `NavProvider` keeps its own order, so two providers on one page never share one. Pinned in `src/react/react.browser.test.tsx` by "keeps sibling host scopes in the order they were opened", "keeps a host trap above a hook scope opened before it", "keeps a nested composite under the trap of the dialog around it" and "leaves one registration per scope under StrictMode, across a rebuild too", and in the shared suite by the three "across a system rebuild" cases and "re-registers a scope when its base changes on a rerender"; `bun run test:browser` passed them on chromium, firefox and webkit on 2026-09-23. What this does not change is the order of the first commit, which is React's: a child's effect runs before its parent's, so a scope a component opens is opened before the one its parent opens in the same commit — the nested case above asserts that order for a composite and the item inside it. That order is why the Dialog recipe at the top of this page passes `within`. **Native radios and ranges in `app` mode keep their arrows when a scope answers `"native"`.** In `app` mode the spatial engine takes every arrow key, so a native radio group moves the focus without checking anything. A handler may return `"native"` beside `true` and `false`: the walk ends before the engine and the key keeps its browser default ([ADR-0026](../adr/0026-native-handler-answer.md)). `useIntent` and `useIntentScopeHost` hand the answer to the bus unchanged. ```tsx function Sizes() { const group = useRef(null); useIntent( (event) => event.source === "keyboard" && (event.intent === "moveUp" || event.intent === "moveDown") && group.current?.contains(document.activeElement) === true ? "native" : false, { within: group }, ); return
…
; } ``` Answer for the keyboard only, since a pad has no native default for a direction, and only along the control's own axis — up and down here, left and right for a range: a television remote's arrows arrive as the same keys, and a native radio group wraps on chromium and firefox, so the other axis has to stay the engine's for a remote user to leave. The reasoning, and why the engine does not decide it itself, is in [Navigation](navigation.md#native-radios-and-ranges-in-app-mode). Pinned in `src/react/react.browser.test.tsx` by "lets a real ArrowDown check the next radio through useIntent" and "hands a host scope's native answer back unchanged"; `bun run test:browser` passed them on chromium, firefox and webkit on 2026-09-23. `react` and `react-dom` are **optional** peer dependencies at `>=18.3.0`; nothing outside `src/react/` imports them, and the adapter is measured with React external. The floor of that range is built, typechecked and run for real: a CI job reinstalls React 18.3 over the lockfile's 19 and runs the browser suite against it, because every other job installs `--frozen-lockfile` and would never have exercised 18. The adapter is held to a shared suite rather than to tests of its own invention: `src/adapter-parity.ts` is the contract any framework adapter has to satisfy — one system and not during the first render, LIFO scope order, a scope released when only its own subtree unmounts, a trap that stops the walk, a base scope reached through that trap, a composite nested in a trapping surface reached when both pass `within` and silenced when neither does, a base re-registered on a rerender without leaving its place, and the order scopes were opened in kept across a system rebuild. The adapters that follow, in the order of [ADR-0011](../adr/0011-package-layout-and-adapters.md), run the same suite before they ship: Vue passes it since 2026-09-23 ([Vue](vue.md)), and Svelte and Angular since 2026-09-24 ([Svelte](svelte.md), [Angular](angular.md)). The vanilla auto-mount helper that shipped before them does not, and [ADR-0023](../adr/0023-vanilla-auto-mount.md) is the record of why: the suite asserts what a provider does across a render, and that helper has neither ([Auto-mount](auto.md)).