Skip to content

React

Render React into real Gtk and Adw widgets. There is no compile plugin and no DOM, and react-dom is not involved. React’s JSX is already a runtime, so two lines of tsconfig.json and two build flags are the whole setup.

You keep React. Components, hooks, context, error boundaries, useState, useEffect and Suspense behave the way they do anywhere else. What changes is the element vocabulary: <gtk-box> instead of <div>.

Install
Terminal
npm install -D @gjsify/gtk-host react react-reconciler @types/react
Terminal
yarn add -D @gjsify/gtk-host react react-reconciler @types/react
Terminal
pnpm add -D @gjsify/gtk-host react react-reconciler @types/react
Terminal
bun add --dev @gjsify/gtk-host react react-reconciler @types/react
Terminal
deno add --dev npm:@gjsify/gtk-host npm:react npm:react-reconciler npm:@types/react
Terminal
gjsify install @gjsify/gtk-host react react-reconciler @types/react

Installing react and react-reconciler is what selects the React adapter. They are optional peers, so nothing enforces a version on install.

Pin React 19. The adapter needs react ^19.2.0 and react-reconciler ^0.33.0. On a React 18 reconciler the synchronous render path is not there to call: flushSync was renamed flushSyncFromReconciler, updateContainerSync and flushSyncWork arrived with React 19, and createContainer grew two arguments in the middle of its list. None of that is a type error.

tsconfig.json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@gjsify/gtk-host/react",
"noImplicitAny": true
}
}

jsxImportSource names the subpath, not react. Write "jsxImportSource": "react" and the element list comes from @types/react, so <div> type-checks in a program that cannot draw one. With the subpath, <div/> is a TS2339 naming the element and <gtk-label label="hi" /> is checked against the real GtkLabel properties.

TypeScript
import { createRoot } from '@gjsify/gtk-host/react';
const root = createRoot(myWindow);
root.render(
<gtk-box orientation="vertical" spacing={12}>
<gtk-label label="hi" />
</gtk-box>,
);

createRoot takes a widget your application already built and records what that container already held, so your tree renders after the app’s own chrome instead of replacing it.

render() commits before it returns, so the widgets are on screen when the call comes back. It also rethrows the first uncaught error at the call site, so a broken tree is a stack trace rather than an empty window.

Terminal
gjsify build src/app.tsx --app gjs --outfile dist/app.gjs.mjs \
--define 'process.env.NODE_ENV="production"' \
--exclude-globals navigator

Both flags are required. Leave either out and the bundle builds, then drags in GTK drawing libraries it never uses.

--define picks React’s production build. Without it you bundle the development reconciler, which reaches for document, HTMLCanvasElement and Path2D; the global scanner answers those by pulling gi://Gdk, GdkPixbuf, Pango and PangoCairo into the bundle.

--exclude-globals navigator covers the scheduler, which reads navigator.scheduling behind a typeof guard. The branch is dead under GJS, but the identifier is still free and the scanner answers it the same way.

Vue needs the same NODE_ENV define and three Vue-specific ones besides, and it needs no navigator flag. The Vue page has that recipe in full.

A setState from a GTK signal handler lands on a later main-loop iteration, which is what you want in a running application. When you need the change on screen immediately, usually in a test with no main loop, flush it:

TypeScript
import { flushSync } from '@gjsify/gtk-host/react';
flushSync(() => setCount(1));
// the label's text has already changed here
ExportWhat it is
createRoot(container, options?)a root over a widget you own: render, unmount, container
mount(element, container, options?)createRoot(…).render(…) in one call
flushSync(fn)run fn and flush every update it schedules
widgetOf(node)the Gtk.Widget behind a host node
adopt(widget)wrap a container you hold in a host element
gtkHostConfigthe raw react-reconciler HostConfig, for reconciler-level control

createRoot takes React 19’s three error callbacks:

OptionFires forDefault
onUncaughtErroran error no boundary caught, so the tree is brokenlogs, and render() rethrows
onCaughtErroran error a boundary did catchconsole.error
onRecoverableErroran error React recovered from and retriedconsole.error
TypeScript
const root = createRoot(myWindow, {
onUncaughtError(error) {
reportToYourOwnSurface(error);
},
});

Passing onUncaughtError turns the rethrow off. That is the right trade for an application with its own error surface, but you then own reporting entirely. Pass it to take over reporting, not to add a log line.

children, ref and key never reach GObject. The adapter reserves children and ref, and React keeps key out of props on its own. Everything else in props is set on the widget, so a typo reads <GtkBox> has no property "colour" instead of doing nothing.

ref gives you your own widget. Where the host wraps a child, a GtkListBoxRow around a row for instance, the ref still hands back the widget you wrote.

TypeScript
import { useEffect, useRef } from 'react';
import type Gtk from '@girs/gtk-4.0';
function SearchField() {
const entry = useRef<Gtk.Entry>(null);
useEffect(() => entry.current?.grab_focus(), []);
return <gtk-entry ref={entry} placeholder-text="Search" />;
}

The first commit does not clear your window. React clears a root before its first commit. Here that reaches your tree only and never touches chrome your application put there.