Skip to content

Gnim

While GTK has its own templating system, its DX is lackluster. Blueprint tries to improve upon it, but it is still not as convenient as JSX. Gnim aims to bring the kind of developer experience to GJS that libraries like React offer for the web.

Gnim is not React

While some concepts are the same, Gnim has nothing in common with React other than the JSX syntax.

Consider the following example:

ts
let win: Gtk.Window

function Box() {
  let counter = 0

  const button = new Gtk.Button()
  const icon = new Gtk.Image({
    iconName: "system-search-symbolic",
  })
  const label = new Gtk.Label({
    label: `clicked ${counter} times`,
  })
  const box = new Gtk.Box({
    orientation: Gtk.Orientation.VERTICAL,
  })

  function onClicked() {
    label.label = `clicked ${counter} times`
  }

  button.set_child(icon)
  box.append(button)
  box.append(label)
  button.connect("clicked", onClicked)
  return box
}

win.set_child(Box())

This can be written as:

tsx
let win: Gtk.Window

function Box() {
  const [counter, setCounter] = createState(0)
  const label = computed(() => `clicked ${counter()} times`)

  function onClicked() {
    setCounter((c) => c + 1)
  }

  return (
    <Gtk.Box orientation={Gtk.Orientation.VERTICAL}>
      <Gtk.Button onClicked={onClicked}>
        <Gtk.Image iconName="system-search-symbolic" />
      </Gtk.Button>
      <Gtk.Label label={label} />
    </Gtk.Box>
  )
}

render(Box, win)

Entry point

To instantiate JSX, you have to render it into a parent object using the render() function. The render function returns a cleanup function which, when invoked, will detach widgets from the parent and dispose of them.

jsx
import { render } from "gnim/gtk4"

const app: Gtk.Application

const dispose = render(() => <Gtk.Window />, app)

app.connect("shutdown", dispose)

JSX Markup

JSX is a syntax extension to JavaScript. It is simply syntactic sugar for function composition. In Gnim, JSX is also used to enhance GObject construction.

Creating and nesting widgets

tsx
function MyButton() {
  return (
    <Gtk.Button onClicked={(self) => console.log(self, "clicked")}>
      <Gtk.Label label="Click me!" />
    </Gtk.Button>
  )
}

Now that you have declared MyButton, you can nest it into another component.

tsx
function MyWindow() {
  return (
    <Gtk.Window>
      <Gtk.Box>
        Click The button
        <MyButton />
      </Gtk.Box>
    </Gtk.Window>
  )
}

Notice that widgets start with a capital letter. Lowercase widgets are intrinsic elements

Displaying Data

JSX lets you put markup into JavaScript. Curly braces let you “escape back” into JavaScript so that you can embed some variable from your code and display it.

tsx
function MyButton() {
  const label = "hello"

  return <Gtk.Button>{label}</Gtk.Button>
}

You can also pass JavaScript to markup properties.

tsx
function MyButton() {
  const label = "hello"

  return <Gtk.Button label={label} />
}

Conditional Rendering

You can use the same techniques as you use when writing regular JavaScript code. For example, you can use an if statement to conditionally include JSX:

tsx
function MyWidget() {
  let content: GnimNode

  if (condition) {
    content = <TrueComponent />
  } else {
    content = <FalseComponent />
  }

  return <Gtk.Box>{content}</Gtk.Box>
}

You can also inline a conditional ? (ternary) expression.

tsx
function MyWidget() {
  return <Gtk.Box>{condition ? <TrueComponent /> : <FalseComponent />}</Gtk.Box>
}

When you don’t need the else branch, you can also use a shorter logical && syntax:

tsx
function MyWidget() {
  return <Gtk.Box>{condition && <TrueComponent />}</Gtk.Box>
}

TIP

falsy values are not rendered and are simply ignored.

Rendering lists

You can use for loops or array map() function.

tsx
function MyWidget() {
  const labels = ["label1", "label2", "label3"]

  return (
    <Gtk.Box>
      {labels.map((label) => (
        <Gtk.Label label={label} />
      ))}
    </Gtk.Box>
  )
}

Widget signal handlers

You can respond to events by declaring event handler functions inside your widget:

tsx
function MyButton() {
  function onClicked(self: Gtk.Button) {
    console.log(self, "was clicked")
  }

  return <Gtk.Button onClicked={onClicked} />
}

How properties are passed

Using JSX, a custom widget will always have a single object as its parameter.

ts
import { type GnimNode } from "gnim"

type Props = {
  myprop: string
  children?: GnimNode
}

function MyWidget({ myprop, children }: Props) {
  //
}

TIP

GnimNode is anything that can be rendered using JSX.

The children property is a special one which is used to pass the children given in the JSX expression.

tsx
// `children` prop of MyWidget is a single GnimNode
return (
  <MyWidget myprop="hello">
    <Gtk.Box />
  </MyWidget>
)
tsx
// `children` prop of MyWidget is a tuple [GnimNode, GnimNode]
return (
  <MyWidget myprop="hello">
    <Gtk.Box />
    <Gtk.Box />
  </MyWidget>
)

State management

State is managed using reactive values (also known as signals or observables in some other libraries) through the Accessor interface. The most common primitives you will use are createState, computed and bind. createState is used to create writable reactive values, computed is used to derive reactive values and bind is used to hook into GObject properties and stores.

tsx
import { createState, computed } from "gnim"

function Counter() {
  const [count, setCount] = createState(0)
  const label = computed(() => count().toString())

  function increment() {
    setCount((v) => v + 1)
  }

  return (
    <Box>
      <Label label={label} />
      <Button onClicked={increment}>Click to increment</Button>
    </Box>
  )
}
tsx
import { Object, register, property } from "gnim/gobject"
import { bind, computed } from "gnim"

@register
class CountStore extends Object {
  @property count: number = 0
}

function Counter() {
  const counter = new CountStore()

  function increment() {
    counter.count += 1
  }

  const count = bind(counter, "count")
  const label = computed(() => count().toString())

  return (
    <Box>
      <Label label={label} />
      <Button onClicked={increment}>Click to increment</Button>
    </Box>
  )
}
tsx
import { createStore, bind, computed } from "gnim"

const countStore = createStore({
  count: 0,
})

function Counter() {
  function increment() {
    counter.count += 1
  }

  const count = bind(counter, "count")
  const label = computed(() => count().toString())

  return (
    <Box>
      <Label label={label} />
      <Button onClicked={increment}>Click to increment</Button>
    </Box>
  )
}

TIP

In a lot of cases you will need to convert values to the required type to pass them as GTK widget properties in which case you can use the .as() method on accessors.

tsx
let value: Accessor<number>

<Gtk.Label label={computed(() => value().toString())} />
<Gtk.Label label={value.as(v => v.toString())} />
<Gtk.Label label={value.as(String)} />

Dynamic rendering

When you want to render based on a reactive value, you can use the <With> component to "unwrap" the Accessor.

tsx
import { With, type Accessor } from "gnim"

let value: Accessor<{ member: string } | null>

return (
  <With value={value}>
    {(value) => value && <Label label={value.member} />}
  </With>
)

TIP

In a lot of cases it is better to always render the component and set its visible property instead.

tsx
const member = computed(() => value()?.member || "")
const shouldShow = computed(() => member() !== "")

return <Label visible={shouldShow} label={member} />

Dynamic list rendering

The <For> component lets you render based on a reactive array dynamically. Each time the array changes, it is compared with its previous state. Widgets for new items are inserted while widgets associated with removed items are disposed.

tsx
import { For, Accessor } from "gnim"

let list: Accessor<Array<T>>

return (
  <For each={list}>
    {(item: T, index: Accessor<number>) => (
      <Label label={index((i) => `${i}. ${item}`)} />
    )}
  </For>
)

Effects

Effects are functions that run when state changes. They can be used to react to value changes and run side-effects such as async tasks, logging or writing Gtk widget properties directly. In general, an effect is considered something of an escape hatch rather than a tool you should use frequently. In particular, avoid using it to synchronise state. See when not to use an effect for alternatives.

The effect primitive runs the given function tracking reactive values accessed within and re-runs it whenever any of its dependencies change.

ts
const [count, setCount] = createState(0)
const [message, setMessage] = createState("Hello")

effect(() => {
  console.log(count(), message())
})

// initial output: 0, "Hello"
setCount(1) // output: 1, "Hello"
setMessage("World") // output: 1, "World"

If you wish to read a value without tracking it as a dependency you can use the .peek() method or the untrack() function.

ts
import { untrack } from "gnim"

effect(() => {
  console.log(count(), message.peek())
  console.log(untrack(() => message()))
})

setCount(1) // output: 1, "Hello"
setMessage("World") // nothing happens

Nested effects

When working with effects, it is possible to nest them within each other. This allows each effect to independently track its own dependencies, without affecting the effect that it is nested within.

ts
effect(() => {
  console.log("Outer effect")
  effect(() => console.log("Inner effect"))
})

The order of execution is important to note. An inner effect will not affect the outer effect. Signals that are accessed within an inner effect will not be registered as dependencies for the outer effect. When the signal located within the inner effect changes, it will trigger only the inner effect to re-run, not the outer one.

ts
effect(() => {
  console.log("Outer effect")
  effect(() => {
    // when count changes, only this effect will re-run
    console.log(count())
  })
})

Root effects

If you wish to create an effect in the global scope, you have to manage its life-cycle with createRoot.

ts
const globalObject: GObject.Object

const field = bind(globalObject, "field")

createRoot((dispose) => {
  effect(() => {
    console.log("field is", field())
  })

  dispose() // effect should be cleaned up when no longer needed
})

When not to use an effect

Do not use an effect to synchronise state.

ts
const [count, setCount] = createState(1)
const [double, setDouble] = createState(count() * 2)
effect(() => {
  setDouble(count() * 2)
})
const double = computed(() => count() * 2)

Same logic applies when an Accessor is passed as a prop.

ts
function Counter(props: { count: Accessor<number> }) {
  const [double, setDouble] = createState(props.count() * 2)
  effect(() => {
    setDouble(props.count() * 2)
  })
  const double = computed(() => props.count() * 2)
}

Avoid using an effect for event-specific logic.

ts
function TextEntry() {
  const [url, setUrl] = createState("")
  effect(() => {
    fetch(url())
  })

  function onTextEntered(entry: Gtk.Entry) {
    setUrl(entry.text)
    fetch(entry.text) 
  }
}

Lifecycles

There are only a few lifecycle functions in Gnim, as everything lives and dies by the reactive system. The reactive system updates synchronously, so the only scheduling comes down to Effects which are scheduled to the end of reactive scopes to make sure every widget ref is alive.

tsx
function MyWindow() {
  let win: Gtk.Window

  effect(() => {
    win.present()
  })

  return <Gtk.Window ref={(self) => (win = self)} />
}

Everything in a Gnim render tree lives inside an Effect and can be nested. You can call onCleanup() in any scope and it will run when that scope is triggered to re-evaluate and when it is disposed.

tsx
function Timer() {
  const [count, setCount] = createState(0)
  const interval = setInterval(() => setCount(count() + 1), 1000)
  onCleanup(() => clearInterval(interval))

  effect(() => {
    console.log("count is", count())
    onCleanup(() => console.log("runs on every tick"))
  })
}