Skip to content

React

Render React into real Gtk and Adw widgets. There is no compile plugin, no DOM and no react-dom. 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, and gjsify has two of them.

Both packages run the same React 19 reconciler against the same host layer, and both produce real GTK widgets in the same window. They differ in what you type for leaves and for layout.

@gjsify/gtk-host/react@gjsify/react-native
Leaves<gtk-label>, <gtk-button>, one tag per GTK class<Text>, <Pressable>, React Native’s own props
LayoutGTK containers, so <gtk-box orientation="vertical"><View> and Yoga flexbox
StylingcssClasses={['card']} and GTK CSSclassName="flex-col gap-m" over token scales you declare
Adwaita widgets168 generated tags, 63 of them Adwthe same tags, in the same tree
Reach for itwhen the GTK window is the app you are writingwhen you are bringing React Native or Expo code to the desktop

The two element sets stay in separate packages, and separate is the right answer. A Gtk.Box with no orientation is horizontal and a React Native View is a column, so one name over both would have to pick a default that is wrong for half of what it renders. GTK reports that kind of wrong at exit 0, with every widget present and every row a column.

Adw.Clamp holds long-form content at a readable width. The Adwaita element is identical on both paths, down to the property names:

@gjsify/gtk-host/react
export function ReadingColumn() {
return (
<adw-clamp maximumSize={400} tighteningThreshold={300}>
<gtk-label
label="It stops growing past the maximum size and stays centred."
wrap
xalign={0}
cssClasses={['card']}
/>
</adw-clamp>
);
}
@gjsify/react-native
import { Text, View } from 'react-native';
import { configureStyle } from '@gjsify/react-native';
// The class VALUES are your project's. The layer's default spacing scale holds `0` and `px`
// only, so a class naming a token you have not declared throws out of the render. React
// unmounts the tree, the window is empty, and the process exits 0 with no GTK diagnostic.
configureStyle({ tokens: { spacing: { m: '12px' }, fontSize: { title: '18px' } } });
export function ReadingColumn() {
return (
<adw-clamp maximumSize={400} tighteningThreshold={300}>
<View className="flex-col gap-m p-m">
<Text className="text-title">Clamped</Text>
<Text>It stops growing past the maximum size and stays centred.</Text>
</View>
</adw-clamp>
);
}

Two things follow from that pair. The Adwaita and GTK tags are available on both paths, because both point jsxImportSource at @gjsify/gtk-host/react, so a React Native tree reaches a clamp, a header bar or a toolbar view without leaving React Native. And the token step is the one line the second path adds that has no counterpart on the first. Style your GTK tags with cssClasses and there is nothing to declare up front.

Have React Native or Expo code already? Take @gjsify/react-native. It is the package a bundler aliases react-native to, so your files keep the imports they have, and every name it does not implement is still exported and refuses with a status and a reason. That page carries the per-name support answers, the routing, and the prop table you can assert against before a window exists.

Starting fresh on GTK? Take @gjsify/gtk-host/react, which is the rest of this page. One tag per GTK class, GTK property names, no style system to configure, and a type error rather than a runtime refusal when you name something that does not exist.

Neither of them runs on a phone. Both render in-process onto GTK 4, so both need libadwaita on the machine. The opposite direction, Adwaita’s widgets rebuilt on React Native’s primitives, is @gjsify/adwaita-react-native, and it is a walking skeleton that has never run on a device.

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 Adw from 'gi://Adw?version=1';
import { createRoot } from '@gjsify/gtk-host/react';
const myWindow = new Adw.ApplicationWindow({ title: 'Counter', defaultWidth: 480 });
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 \
--exclude-globals navigator

--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. Without it the bundle is ~50 bytes larger and prints a spurious GI note.

NODE_ENV is now a build default — the same process.env.NODE_ENV="production" define that React’s development reconciler needs is set automatically on --app gjs and --app browser builds. A user --define still overrides it. --app node leaves it out: Node has a real process.env, so the bundle reads NODE_ENV at run time like its source does.

Vue needs the same NODE_ENV default and three Vue-specific defines 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 { useState } from 'react';
import { flushSync } from '@gjsify/gtk-host/react';
function Counter() {
const [count, setCount] = useState(0);
const bump = () => {
flushSync(() => setCount(1));
// the label's text has already changed here
};
return <gtk-button label={`clicked ${count}x`} onClicked={bump} />;
}
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 that reports its own errors, 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.