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.
Two vocabularies, one React
Section titled “Two vocabularies, one React”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 |
| Layout | GTK containers, so <gtk-box orientation="vertical"> | <View> and Yoga flexbox |
| Styling | cssClasses={['card']} and GTK CSS | className="flex-col gap-m" over token scales you declare |
| Adwaita widgets | 168 generated tags, 63 of them Adw | the same tags, in the same tree |
| Reach for it | when the GTK window is the app you are writing | when 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.
The same window, written both ways
Section titled “The same window, written both ways”Adw.Clamp holds long-form content at a readable width. The Adwaita element is identical on
both paths, down to the property names:
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> );}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.
Which one to pick
Section titled “Which one to pick”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
Section titled “Install”npm install -D @gjsify/gtk-host react react-reconciler @types/reactyarn add -D @gjsify/gtk-host react react-reconciler @types/reactpnpm add -D @gjsify/gtk-host react react-reconciler @types/reactbun add --dev @gjsify/gtk-host react react-reconciler @types/reactdeno add --dev npm:@gjsify/gtk-host npm:react npm:react-reconciler npm:@types/reactgjsify install @gjsify/gtk-host react react-reconciler @types/reactInstalling 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.
Configure TypeScript
Section titled “Configure TypeScript”{ "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.
Render a tree
Section titled “Render a tree”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.
Build it
Section titled “Build it”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.
Update outside a render
Section titled “Update outside a render”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:
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} />;}What the adapter exports
Section titled “What the adapter exports”| Export | What 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 |
gtkHostConfig | the raw react-reconciler HostConfig, for reconciler-level control |
Handle errors
Section titled “Handle errors”createRoot takes React 19’s three error callbacks:
| Option | Fires for | Default |
|---|---|---|
onUncaughtError | an error no boundary caught, so the tree is broken | logs, and render() rethrows |
onCaughtError | an error a boundary did catch | console.error |
onRecoverableError | an error React recovered from and retried | console.error |
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.
Three differences from react-dom
Section titled “Three differences from react-dom”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.
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.
Related
Section titled “Related”- Getting started if you do not have a gjsify project yet
- UI Frameworks for the host, the widget table and the placement rules
- React Native for the other vocabulary, if you are bringing
code written with
ViewandText - Styling on GTK for class names and token scales
react-host-counterfor a complete window that asserts its own widget tree on every launch- How the React layers work for the measurement behind each rule above, if you are changing this part of gjsify