Skip to content

shayc/react-built-in-ai

Repository files navigation

@shayc/react-built-in-ai

npm version CI License: MIT

A thin React layer over the browser's Built-in AI APIs — models the browser downloads and runs on-device, including Gemini Nano (which powers the Prompt and writing APIs). Six task APIs plus the Prompt API (LanguageModel), each with a React hook and an imperative creator.

Used raw, these APIs make you hand-roll availability probes, user-activation-gated downloads, downloadprogress wiring, instance cleanup, and deduplication across components. The hooks fold all of that into one lifecycle state machine: read status and progress, call the task method. TypeScript-first, with option and return types exported for every API; ESM, no runtime dependencies (one type-only package).

Browser support — Chromium only (Chrome 138+, Edge); not Firefox or Safari. Some of the globals are still gated by Chrome flags or an origin trial, and all are absent on unsupported builds — feature-detect with isSupported() and render a fallback.

Install

npm install @shayc/react-built-in-ai

Requires React 18 or 19 (peer dependency). Client-only — add "use client" in RSC setups (e.g. the Next.js app router).

Quick start

import { useTranslator } from "@shayc/react-built-in-ai";

function Translate() {
  const translator = useTranslator({
    sourceLanguage: "en",
    targetLanguage: "es",
  });

  // Non-Chromium browser — see Capability check for feature detection.
  if (translator.status === "unsupported") return <p>Not supported here.</p>;

  return (
    <button
      disabled={translator.status === "downloading"}
      onClick={async () => alert(await translator.translate("Hello, world."))}
    >
      Translate
    </button>
  );
}

On a fresh browser, the first click triggers the model download (gated by user activation); subsequent clicks call translate directly. See Lifecycle for the full state machine.

API

Browser API React hook Imperative creator Chrome
Writer useWriter createWriter 138+
Rewriter useRewriter createRewriter 138+
Summarizer useSummarizer createSummarizer 138+
Proofreader useProofreader createProofreader 138+
Translator useTranslator createTranslator 138+
Language Detector useLanguageDetector createLanguageDetector 138+
Prompt ¹ useLanguageModel createLanguageModel 148+ (138+ in ext.)

¹ Available earlier via the official prompt-api-polyfill. It's also a different shape — a stateful chat session, not a stateless task; see Prompt API for how useLanguageModel diverges from the other hooks.

Use the hook when options are known at render time (e.g. a translator bound to the user's current language pair). Use the creator when options are decided mid-flow and a hook can't be driven (queued work, command palettes, one-shot scripts).

Every hook shares the lifecycle surface plus task-specific methods (translate, rewrite, proofread, summarize, write, detect), along with streaming and measureInput variants where the underlying API supports them. Exceptions:

  • useProofreader exposes only proofread — the browser API offers no streaming, measureInput, or inputQuota.
  • useLanguageDetector has no streaming variant, and its detect resolves with an array of ranked { detectedLanguage, confidence } candidates rather than a string.
  • useLanguageModel is a stateful conversation rather than a one-shot task — see Prompt API.

Capability check

Feature-detect before mounting any hook:

import { isSupported } from "@shayc/react-built-in-ai";

if (!isSupported("Translator")) return <Fallback />;

isSupported(name) returns true when the matching global ("Writer", "Rewriter", "Summarizer", "Proofreader", "Translator", "LanguageDetector", "LanguageModel") is present on globalThis. Combine with the hook's status ("unavailable") for the full readiness picture — the global can exist on a device that still can't run the model.

Checking availability without a hook

checkAvailability(name, options) runs the same on-device readiness probe every hook and creator uses internally, without mounting a hook or creating an instance. Use it for a capability list or settings screen that needs a real status for options the user hasn't committed to yet. Options are optional for every API except "Translator", which needs a language pair to answer meaningfully:

import { checkAvailability } from "@shayc/react-built-in-ai";

const availability = await checkAvailability("Translator", {
  sourceLanguage: "en",
  targetLanguage: "es",
});
// "available" | "downloadable" | "downloading" | "unavailable"

It throws UnsupportedError when the global is absent — check isSupported() first if you'd rather branch on that yourself. Unlike the hook's status, this is a one-shot probe: it doesn't stay in sync if availability changes afterward.

Lifecycle

Every hook exposes status, progress, error, and prepare. The paths through the state machine:

checking → ready                                   model already on device
checking → downloadable → downloading → ready      download needed — starts on a user gesture
checking | downloading → error                     probe/create rejected — prepare() retries
unsupported / unavailable                          terminal — no global / device can't run the model

status is always one of:

Terminal states:

  • unsupported — the global namespace is missing on this browser.
  • unavailable — the model reports it cannot run on this device.

Transition states:

  • checking — supported; probing availability, or quietly creating an already-downloaded model. Passes through to ready on its own.
  • downloadable — the model needs a download, which the browser only starts from a user activation. Render your download affordance off this state; prepare() (or any action method) called from a user gesture moves it along. (The name mirrors the browser's availability() vocabulary.) Edge case: a gesture-less call doesn't fail immediately — it re-checks availability first, in case another component or tab finished the download since this hook last looked, and joins an in-flight download if it finds one.
  • downloading — a download is running. progress is null until the browser reports a fraction via a downloadprogress event, then a number — no starting value is ever fabricated, so a joined download that's already partway done isn't misreported as 0. The download may have been started by this hook or joined passively (another hook with different options, another tab, or an imperative create* call already had it in flight).

Active state:

  • ready — the instance is live; action methods can be called freely.

Failure state:

  • erroravailability() or create() rejected. Call prepare() (from a user activation if a download is required) to tear down and re-initialize.

Instance sharing

Hooks with equal options — same namespace, same options by value — share one underlying model instance and one status/progress/error. The instance is created on the first mount that needs it and torn down when the last component using those options unmounts; everything in between (including a prepare() retry from error) is visible to every component sharing it. This means two components — say, a settings panel showing download status and a toolbar actually using the model — stay in sync automatically as long as they're called with the same options.

useLanguageModel is the one exception: each mount owns a private session (conversations must stay isolated, and its options can't be structurally keyed), so equal-options mounts never share an instance. See Prompt API.

Troubleshooting

  • unsupported on a current Chrome — some APIs are still flag- or origin-trial-gated depending on the API and Chrome release (the Prompt API on the web most notably). Check the Chrome doc linked in the API table for current status.
  • unavailable — the device doesn't meet Chrome's hardware or storage requirements for that model (also listed in each API's Chrome doc). Render a fallback; retrying won't help.
  • Stuck on downloadable — downloads start only from a user gesture. Call prepare() or any action method from a click/keypress handler, not from an effect.

Handling the lifecycle in UI

Action methods are gated by the lifecycle — they throw UnsupportedError, UnavailableError, MissingUserActivationError, or NotReadyError when the state forbids them. A call rejected by the gate never mutates the hook's status or error. (A call made from downloadable with a user activation is not gate-rejected — it drives status through downloading to ready or error like prepare().)

function Demo() {
  const translator = useTranslator({
    sourceLanguage: "en",
    targetLanguage: "es",
  });

  // 1. Guard against browsers/devices that can't run the model.
  if (translator.status === "unsupported") return <p>Not supported.</p>;
  if (translator.status === "unavailable") return <p>Not available.</p>;

  return (
    <button
      // 2. Block re-entry while the model is downloading.
      disabled={translator.status === "downloading"}
      onClick={async () => {
        // 3. The click is a user activation, so the hook is allowed to start
        //    the download here if status was "downloadable"; otherwise it runs
        //    at once.
        const out = await translator.translate("…some text…");
        console.log(out);
      }}
    >
      {translator.status !== "downloading"
        ? "Translate"
        : translator.progress === null // null until the browser reports a fraction
          ? "Downloading…"
          : `Downloading (${Math.round(translator.progress * 100)}%)`}
    </button>
  );
}

Streaming

Accumulate chunks into React state to render incrementally:

const [output, setOutput] = useState("");

async function handleTranslate(text: string) {
  setOutput("");
  for await (const chunk of translator.translateStream(text)) {
    setOutput((prev) => prev + chunk);
  }
}

Imperative creators

try {
  await using translator = await createTranslator({
    sourceLanguage,
    targetLanguage,
  });
  const text = await translator.translate(input);
} catch (error) {
  if (!(error instanceof BuiltInAIError)) throw error;
  // unsupported / unavailable / missing-activation — render a fallback.
}

Each create* mirrors the hook lifecycle exactly — same three typed errors (UnsupportedError, UnavailableError, MissingUserActivationError), same progress wiring. Unlike with the hooks, other browser rejections surface unchanged — most commonly AbortError when signal fires, or NetworkError on a failed download. The instanceof BuiltInAIError check above is what separates the typed lifecycle errors from those pass-throughs.

The returned instance is AsyncDisposable — prefer await using (TypeScript 5.2+) so it's released on scope exit. On older toolchains, or to release earlier, call .destroy() in a finally.

Each creator accepts the same options as its hook, plus an optional signal that cancels both the download (if any) and the underlying create() call.

A creator requires a user activation only to start a download (availability() reporting "downloadable") — prefer calling it from an event handler, or pre-warm the model via the matching hook elsewhere in the tree before the call site is reached. If a download is already in flight elsewhere ("downloading"), the creator joins it gesture-free, same as a hook that finds one on probe.

Download progress

  • Per-instance — read progress and status from the hook return, or from a creator's own thrown/awaited lifecycle.
  • Cross-instanceuseGlobalDownloadProgress(namespaces?) reports the progress of the least-complete in-flight download across every instance, regardless of which component (or imperative caller) initiated the download. The value never moves backwards when one of several downloads finishes, and returns to null once all of them complete — finished downloads stop being tracked, so key "done" off null, not progress === 1. Pass a namespace ("Writer", "Rewriter", "Summarizer", "Proofreader", "Translator", "LanguageDetector", "LanguageModel") or an array of namespaces to scope the aggregation, or call with no argument to track all Built-in AI downloads.
function GlobalDownloadBar() {
  const progress = useGlobalDownloadProgress();
  if (progress === null) return null;
  return <ProgressBar value={progress} />;
}

Option changes

Options are compared structurally — sorted and compared by content, not by reference — so inline object/array literals are safe without memoization: a fresh literal with the same content resolves to the same underlying instance.

When a component's options change, it re-enters the state machine. If it was the last component holding the old options, that instance is destroyed and its in-flight work aborted with AbortError; if another component still holds the old options, that instance keeps running for it — an option change never disturbs components still sharing the old instance.

This applies to the six task hooks. useLanguageModel captures its options once, at mount, and ignores later changes — see below.

Prompt API

useLanguageModel wraps the Prompt API (LanguageModel) — a stateful chat session rather than a stateless task. It shares the same lifecycle (status, progress, error, prepare) and the same download/gesture rules as every other hook, but three things differ by design:

  • Each mount owns a private session. Equal-options mounts never share one instance — sharing would cross-contaminate two components' conversations, and the options (closures in tools, blobs in initialPrompts) can't be keyed by value anyway. The expensive part — the model download — is still deduplicated by the browser across every session, so concurrent mounts join the same in-flight download gesture-free.
  • Options are captured at mount and never re-read from props. A stateful conversation must not be silently discarded because a parent re-rendered with a fresh literal — so inline option literals are safe here precisely because they're ignored after the first render. Change options explicitly with reset(nextOptions) or a key= remount.
  • The session is not exposed. For the raw LanguageModel (to clone(), or drive destroy() yourself), use createLanguageModel().
function Chat() {
  const model = useLanguageModel({
    initialPrompts: [
      { role: "system", content: "You are a helpful assistant." },
    ],
  });
  const [reply, setReply] = useState("");

  if (model.status === "unsupported") return <p>Prompt API not supported.</p>;

  return (
    <>
      <button
        disabled={model.status !== "ready"}
        onClick={async () => setReply(await model.prompt("Say hi in French."))}
      >
        Ask
      </button>
      <p>{reply}</p>
      <small>
        {model.contextUsage} / {model.contextWindow} tokens
      </small>
    </>
  );
}

The session methods:

Core interaction:

  • prompt(input, options?) / promptStream(input, options?) — send a turn and get the full response, or stream it (concatenating chunks yields the same result). Both commit the turn to history.
  • append(input, options?) — add to the history without prompting for a response, e.g. to pre-load context.

Session management:

  • measureContext(input, options?) — estimate how many tokens input would add, without sending it.
  • reset(nextOptions?) — discard the conversation and provision a fresh session (with the same options, or a full replacement). Aborts in-flight calls with AbortError, destroys the old session, and zeroes contextUsage / overflowCount.

input is a string or a message array; options accepts responseConstraint (a JSON schema or RegExp for structured output) and signal. Multimodal input (expectedInputs with image / audio) and assistant-message prefix: true continuations pass through the create/prompt options unchanged.

Context window and overflow

  • contextUsage grows every turn; contextWindow is the session's fixed budget (both 0 until ready).
  • overflowCount counts contextoverflow events — the browser auto-evicting old turns once the window fills. useEffect on it to drive the session-compacting pattern: summarize the transcript (useSummarizer pairs naturally), then reset with the summary as compacted initialPrompts.
const model = useLanguageModel({ initialPrompts });

useEffect(() => {
  if (model.overflowCount === 0) return;
  // Summarize the running transcript you've been tracking, then restart the
  // session with a compacted context — the documented compacting recipe.
  const compacted = compactTranscript();
  model.reset({ initialPrompts: compacted });
}, [model.overflowCount]);

Output variety (temperature / topK)

Not exposed. In stable Chrome on the web, the raw topK / temperature params are silently ignored, and the spec's proposed replacement (samplingMode presets) hasn't shipped — neither is a working knob yet. samplingMode is still accepted in the options type (the browser ignores it until it ships); the type will widen once the surface settles.

Errors

Lifecycle gating throws BuiltInAIError subclasses. Action methods (translate, rewrite, …) pass the browser API's own rejections through unchanged — most commonly an AbortError DOMException when a signal fires. When the lifecycle wraps a browser rejection into "error" state, the original error is preserved as error.cause.

Error What to do
UnsupportedError The namespace is missing. Feature-detect with isSupported() and render a fallback.
UnavailableError The device can't run the model. Render a fallback; don't retry.
MissingUserActivationError A download needed to be started without a user gesture. Trigger prepare() (or the first action) from a click/keypress handler.
NotReadyError A prior create() failed. Call prepare() from a user activation to retry; inspect error.cause for the underlying reason.

Components with equal options share one lifecycle: if another component sharing your options calls prepare() to retry from "error", that restarts the shared store — any of your own in-flight action calls reject with a DOMException named AbortError. Filter it like any other cancellation (error.name === "AbortError"), not as a failure.

Cancellation

A per-call signal cancels the caller's wait and the underlying action call, but does not tear down the shared model instance. If the hook is mid-download, aborting one call rejects that call with AbortError while the download keeps running for any other caller (and for the next call from the same component). The download is only cancelled when the last component sharing it unmounts (or changes options such that it's no longer the last holder). A sibling component's prepare() retry can also abort your in-flight call — see Errors above.

Security

No network calls — everything runs against the browser's on-device model. Releases are published to npm with provenance attestations so the bytes you install can be traced back to a specific GitHub Actions run.

Found a security issue? Open a private advisory at github.com/shayc/react-built-in-ai/security/advisories/new.

Versioning

Semver; see CHANGELOG.md.

Contributing

See CONTRIBUTING.md for development setup (Node 22+, Vitest browser mode, the changeset workflow).

License

MIT © Shay Cojocaru

About

React hooks for the browser's Built-in AI APIs — Writer, Rewriter, Summarizer, Proofreader, Translator, Language Detector, and the Prompt API.

Resources

License

Contributing

Stars

1 star

Watchers

0 watching

Forks

Packages

 
 
 

Contributors