# NAC3 + React adoption guide > **v2.3.0-alpha.1 preview now available (2026-05-12)** -- adds `NAC.invoke` and `NAC.utter` for payload-bearing actions. The v2.2 contracts described in this document still hold; v2.3 is additive. See SPEC.md sec 5.1.4 and CHANGELOG for details. This guide gets a React app NAC-driven in two paths: - **Greenfield:** new project, NAC3 from day one. - **Brownfield:** existing app, NAC3 added progressively without a rewrite. Both use `@nac3/runtime` from npm. No build-step assumptions; this works with Vite, Next.js, Create React App, Remix, or anything that bundles a normal package. --- ## 1. Install ``` npm install @nac3/runtime ``` The package exposes the runtime as `window.NAC` after first import. The runtime is framework-agnostic; React just decorates JSX with `data-nac-*` attributes and registers manifests via `useEffect`. --- ## 2. Greenfield -- new app ### 2.1 Mount the runtime once In your root component (or `main.tsx` / `_app.tsx`): ```tsx import { useEffect } from 'react'; import '@nac3/runtime'; import '@nac3/runtime/extensions'; // v2.0 brownfield primitives + HMAC // optional: '@nac3/runtime/chat-client' for voice + chat export function App() { useEffect(() => { // Tenant prefix (multi-tenant SaaS pattern). Skip if single-tenant. if (window.NAC?.setTenantPrefix) { window.NAC.setTenantPrefix('demo'); } // HMAC secret if you ship signed manifests. Get from your auth API. // window.NAC.set_provenance_secret(secret); }, []); return ; } ``` ### 2.2 Decorate components Every clickable / fillable / switchable element gets: - `data-nac-id` -- a stable dotted path. - `data-nac-role` -- one of the canonical roles (see SPEC sec 1). - `data-nac-action=""` -- only for `role="action"`. ```tsx function InvoiceForm({ invoice, onSave, onCancel }) { return (
/* ... */} />
); } ``` ### 2.3 Register a manifest The manifest is the agent-facing source of truth. An LLM resolving "guardar" finds the verb `save` here: ```tsx import { useEffect } from 'react'; const INVOICE_MANIFEST = { plugin_slug: 'invoice', version: '1.0.0', nac_version: '2.1', elements: [ { id: 'invoice.client_name', role: 'field', label_i18n: { es: 'Nombre del cliente', en: 'Customer name', pt: 'Nome do cliente', fr: 'Nom du client', it: 'Nome del cliente', de: 'Kundenname', ja: '顧客名', zh: '客户名称', hi: 'ग्राहक का नाम', ar: 'اسم العميل' } }, { id: 'invoice.save', role: 'action', actions: [{ verb: 'save', label_i18n: { /* 10 locales */ } }], label_i18n: { /* 10 locales */ } }, { id: 'invoice.cancel', role: 'action', actions: [{ verb: 'cancel', label_i18n: { /* 10 locales */ } }], label_i18n: { /* 10 locales */ } } ] }; export function InvoiceForm(props) { useEffect(() => { if (!window.NAC) return; window.NAC.register(INVOICE_MANIFEST); }, []); // ... JSX from 2.2 ... } ``` Key rules: - `useEffect` with `[]` deps: register once on mount. - The manifest is a static object; do not rebuild it on every render (the runtime treats `register` as idempotent but you waste cycles). - React Strict Mode double-invokes effects in dev. The runtime's `register` is idempotent; safe. ### 2.4 Emit success events from handlers If the runtime is going to be driven by an agent that awaits `NAC.click()`, your handlers must emit `nac:action:succeeded` after their side effect: ```tsx function onSave() { await api.saveInvoice(/* ... */); document.dispatchEvent(new CustomEvent('nac:action:succeeded', { detail: { plugin: 'invoice', action_id: 'invoice.save' } })); } ``` This is the v2.1 contract. v2.2 ships a `useNACAction` hook that does this for you (see Hooks section below). ### 2.5 Drive it From any agent, voice runner, or test: ```tsx await window.NAC.click('invoice.save'); // or by verb: await window.NAC.click_by_verb('invoice', 'save'); // or fill a field: await window.NAC.fill('invoice.client_name', 'Acme Corp'); ``` --- ## 3. Brownfield -- existing React app The principle: do not refactor everything at once. Add NAC3 to one component, validate, repeat. ### 3.1 Order of attack 1. **Top-level wrapper first.** Add `data-nac-plugin=""` to your root `
` or `
`. The runtime's scope tree picks it up. 2. **Most-used buttons next.** Save, cancel, submit, delete in your busiest screens. Add `data-nac-id`, `data-nac-role="action"`, `data-nac-action=""`. Don't add a manifest yet. 3. **Verify the runtime sees them.** Open DevTools, run `NAC.describe()`. The buttons should appear under their plugin slug. 4. **Add a minimal manifest.** Just the buttons from step 2, with their verbs. Now `NAC.click_by_verb()` works. 5. **Add fields.** Inputs get `data-nac-role="field"` + manifest entries. 6. **Add tabs.** Tab switchers get `data-nac-role="tab"`. **Critical:** ids matching `^tab\.` MUST have role `tab` (the runtime's `NAC.tab()` query is canonical-role-only; see SPEC sec 1). ### 3.2 Don't fight your existing component library You probably use shadcn / Mantine / MUI / Chakra / your-custom-system. Most of these render their own DOM. Two patterns work: **Pattern A: pass NAC3 attrs through.** Most well-built libraries forward unknown props to the underlying DOM element: ```tsx ``` If your library forwards `data-*` attrs, this is enough. **Pattern B: wrapper component.** If your library swallows `data-*` props, write a tiny wrapper: ```tsx import { Button as MuiButton } from '@mui/material'; import { useEffect, useRef } from 'react'; interface NACButtonProps { nacId: string; verb: string; // ...other Mui props } export function NACButton({ nacId, verb, ...rest }: NACButtonProps) { const ref = useRef(null); useEffect(() => { if (!ref.current) return; ref.current.setAttribute('data-nac-id', nacId); ref.current.setAttribute('data-nac-role', 'action'); ref.current.setAttribute('data-nac-action', verb); }, [nacId, verb]); return ; } ``` ### 3.3 Auto-register from DOM If declaring manifests by hand is tedious, the v2.0 extension `autoRegister.watch` walks the DOM and registers anything with `data-nac-id` + `data-nac-role` automatically: ```tsx useEffect(() => { if (!window.NAC?.autoRegister) return; const root = document.querySelector('[data-nac-plugin]'); if (!root) return; root.setAttribute('data-nac-watch', '1'); window.NAC.autoRegister.watch(root, { i18n_strict: 'permissive', // accept partial 10-locale during migration throttleMs: 100 }); }, []); ``` `i18n_strict: 'permissive'` is right for brownfield. For production, switch to `'strict'` once your i18n catalogue is complete. --- ## 4. Hooks (v2.2 preview) These ship in v2.2. For v2.1 you can copy them into your project today; they wrap the v2.1 runtime and provide a more idiomatic React API. ### 4.1 `useNACManifest` ```tsx export function useNACManifest(manifest) { useEffect(() => { if (!window.NAC) return; window.NAC.register(manifest); }, [manifest.plugin_slug]); // re-register only on slug change } ``` ### 4.2 `useNACAction` -- auto-emit ack ```tsx import { useEffect, useRef } from 'react'; export function useNACAction(plugin: string, actionId: string) { const ref = useRef(null); useEffect(() => { const el = ref.current; if (!el) return; function onClick() { // Emit the v2.1 contract event after the React onClick runs. // Microtask delay so React's synthetic event finishes first. queueMicrotask(() => { document.dispatchEvent(new CustomEvent('nac:action:succeeded', { detail: { plugin, action_id: actionId } })); }); } el.addEventListener('click', onClick); return () => el.removeEventListener('click', onClick); }, [plugin, actionId]); return ref; } ``` Usage: ```tsx function SaveButton({ onSave }) { const ref = useNACAction('invoice', 'invoice.save'); return ( ); } ``` ### 4.3 `useNACDescribe` -- introspect the tree from a panel ```tsx import { useState, useEffect } from 'react'; export function useNACDescribe() { const [snap, setSnap] = useState(null); useEffect(() => { if (!window.NAC) return; setSnap(window.NAC.describe()); const tick = setInterval(() => setSnap(window.NAC.describe()), 1000); return () => clearInterval(tick); }, []); return snap; } ``` --- ## 5. Testing ### 5.1 Unit + integration NAC3 plays nicely with React Testing Library: ```tsx import { render, fireEvent, waitFor } from '@testing-library/react'; import '@nac3/runtime'; import { InvoiceForm } from './InvoiceForm'; test('save button drives via NAC', async () => { render(); const saved = jest.fn(); document.addEventListener('nac:action:succeeded', saved); await window.NAC.click('invoice.save'); await waitFor(() => expect(saved).toHaveBeenCalled()); }); ``` ### 5.2 End-to-end (Playwright) ```ts import { test, expect } from '@playwright/test'; test('invoice save', async ({ page }) => { await page.goto('/invoices/new'); await page.evaluate(() => window.NAC.fill('invoice.client_name', 'Acme')); await page.evaluate(() => window.NAC.click('invoice.save')); await expect(page.getByText('Invoice saved')).toBeVisible(); }); ``` --- ## 6. Common gotchas - **Stale ids in keyed lists.** If you build ids from a row index (`data-nac-id={'row.' + i}`) and rows reorder, agents that cached the id break. Use stable keys (DB ids). - **Conditional rendering.** A button that mounts/unmounts based on `if (loaded)` confuses an LLM that snapshotted the tree before load. Tell the LLM via `NAC.describe()` includes a `mounted` flag per element (v2.1 always-on); your snapshot consumer should filter by that. - **React 18 Strict Mode.** Double-invoked effects re-register the manifest. The runtime is idempotent; safe but you'll see double log lines in dev. - **Server components / SSR.** NAC3 is client-only. Mark NAC-using components with `'use client'` (Next.js App Router) or render them lazily. --- ## 7. Going to production Before shipping: 1. Replace `i18n_strict: 'permissive'` with `'strict'`. CI catches missing translations. 2. Run `npx @nac3/runtime validate ./src` -- expect zero error-severity findings. 3. Run `NAC.validate_global()` from a Playwright test; assert it returns `[]`. 4. If multi-tenant, ensure manifests are HMAC-signed server-side and `NAC.set_provenance_secret()` is called from authed code. --- ## 8. Where to go next - `SPEC.md` for the full contract. - `guides/LLM_WIRING.md` for the intermediary backend that resolves "guardar la factura" into `NAC.click_by_verb('invoice','save')`. - `SECURITY.md` for the threat model. - The demos at yujin.app/nac-spec/ (`example.php` is the v1.9 reference; `example-v20-full.php` is the brownfield migration story).