Primitives
Gnim is built around the Accessor primitive, which is a read-only reactive value. Accessors are essentially functions that let you read a value and track it in reactive scopes so that when it changes, the reader is notified.
interface Accessor<T> {
(): T
peek(): T
as<R = T>(fn: (value: T) => R): Accessor<R>
}There are two ways to read the current value:
(): T: which returns the current value and tracks it as a dependency in reactive scopespeek(): Twhich returns the current value without tracking it as a dependency
.as() can be used to simply map the value without doing any memoization or validation.
const n: Accessor<number>
const s: Accessor<string> = n.as((v) => v.toString())IMPORTANT
The body of .as() is run on each access. If you need memoization use computed().
Gnim's reactive system is synchronous: Setting a state notifies synchronously: by the time the setter returns, every computed depending on it is marked stale and every effect and subscribe callback depending on it has already re-run.
const [s, setS] = createState(1)
const a = computed(() => s() * 10)
const b = computed(() => s() * 100)
const sum = computed(() => a() + b())
subscribe(sum, () => console.log(sum.peek()))
setS(2) // logs 220, exactly oncecreateState
Creates a writable reactive value.
function createState<T>(
init: T,
opts?: StateOptions<T>,
): [Accessor<T>, Setter<T>]
interface StateOptions<T> {
equals?(prev: T, next: T): boolean
}Example:
const [value, setValue] = createState(0)
// setting its value
setValue(2)
setValue((prev) => prev + 1)The producer form receives the latest value, including one set earlier in the same batch.
By default, equality between the previous and new value is checked with Object.is and so this would not trigger an update:
const [object, setObject] = createState({})
// this does NOT trigger an update by default
setObject((obj) => {
obj.field = "mutated"
return obj
})You can pass in a custom equals function to customize this behavior, for example to always notify subscribers:
const [value, setValue] = createState("initial value", {
equals: () => false,
})computed
Creates a derived value that tracks its dependencies and memoizes the result.
function computed<T>(
compute: (prev?: T) => T,
opts?: StateOptions<T>,
): Accessor<T>
interface StateOptions<T> {
equals?(prev: T, next: T): boolean
}NOTE
This operation is also known as a memo in other libraries.
Example:
let a: Accessor<number>
let b: Accessor<number>
const c: Accessor<number> = computed(() => a() + b())The computation is lazy: it runs on the first read, and then at most once per dependency change, on the next read. Observers are only notified when the result changed according to equals, which defaults to Object.is.
bind
Creates an Accessor on a GObject.Object's property or a Store's field.
type Bindable = Store | GObject.Object
function bind<T extends Bindable, K extends PropKeys<T>>(
bindable: T,
key: K,
): Accessor<Prop<T, K>>IMPORTANT
bind infers available properties from the $readableProperties annotation and falls back to keyof T when it's empty or missing.
Example:
const styleManager = Adw.StyleManager.get_default()
const style = bind(styleManager, "color-scheme")For GObject properties the get_* getter is preferred over the plain property, and the notify::* signal is only connected while something tracks the accessor.
It also supports nested bindings. The accessor resolves to null or undefined when a link in the chain is.
interface Outer extends GObject.Object {
nested: Inner | null
}
interface Inner extends GObject.Object {
field: string
}
const value: Accessor<string | null> = bind(outer, "nested", "field")effect
Schedule a function to run after the current Scope returns, tracking dependencies and re-running the function whenever they change.
function effect<T>(fn: (prev?: T) => T): () => voidExample:
const count: Accessor<number>
effect(() => {
console.log(count()) // reruns whenever count changes
})
const dispose = effect(() => {
console.log(count.peek()) // only runs once, equivalent to `onMount`
})
dispose() // can be stopped manuallyCAUTION
Effects are a common pitfall for beginners when deciding when to use them and when not to use them. You can read about when it is discouraged and their alternatives.
subscribe
Subscribes to value changes.
function subscribe<T>(track: () => void, callback: (prev?: T) => T): () => voidtrack is run to collect dependencies: pass an accessor directly, or a function that reads several. Whenever one of them changes, dependencies are collected again and callback runs. Reads inside callback are not tracked.
const a: Accessor<number>
const b: Accessor<number>
subscribe(a, () => {
console.log("value of a changed to", a.peek())
})
const unsubscribe = subscribe(
() => {
a()
b()
},
() => console.log("a or b changed"),
)
unsubscribe() // can be stopped manuallyWARNING
Do not forget to clean them up outside of tracking scopes when no longer needed
untrack
Lets you read Accessor and Store without tracking them.
const value: Accessor<T>
const store: Store<{ field: string }>
const _ = untrack(() => value())
const _ = value.peek() // same as above
const _ = untrack(() => store.field)batch
Groups multiple updates so that effects and subscriptions run once after the batch completes instead of after each individual update.
function batch(fn: () => void): voidExample:
const [count, setCount] = createState(0)
const [total, setTotal] = createState(0)
effect(() => console.log(`${count()} / ${total()}`)) // logs "0 / 0"
setCount(1) // logs "1 / 0"
setTotal(5) // logs "1 / 5"
batch(() => {
setCount(2)
setTotal(10)
}) // logs "2 / 10"Reads inside the batch see the latest values. Nested batches flush once the outermost one completes.
connectSignal
Connecting to GObject signals can be done via a pair of .connect() and onCleanup(). This is a shorter version of exactly that.
const id = object.connect("signal", (_, ...args) => {
// handler
})
onCleanup(() => object.disconnect(id))Can be written as:
connectSignal(object, "signal", (...args) => {
// handler
})createStore
Creates an object where each field is replaced with a reactive accessor. Plain fields become writable states, getters become memoized computed values and methods are kept as they are.
const store = createStore({
value: 0,
get double() {
return this.value * 2
},
})Stores under the hood
The above example can be thought of as a set of values created with createState and computed exposed through object property accessors.
function createMyStore() {
const [value, setValue] = createState(0)
const double = computed(() => value() * 2)
return {
get value() {
return value()
},
set value(v) {
setValue(v)
},
get double() {
return double()
},
}
}Accessing store values is reactive.
const v = computed(() => store.value)
effect(() => {
console.log(store.value)
})To read a store value in a reactive scope without tracking it as a dependency use untrack.
effect(() => {
console.log(untrack(() => store.value))
})To pass them as reactive props you can use bind.
<Component value={bind(store, "value")} />Nested stores
When mutating a value which is a nested store make sure to use createStore to not lose reactivity.
const store = createStore({
value: 0,
nested: createStore({
value: 0,
}),
})
store.nested = createStore({
...store.nested,
})Note that using the spread operator assigns values meaning that derived values defined using the getter syntax will no longer track their dependencies.
createAccessor
Creates an Accessor over a getter function.
function createAccessor<T>(
get: () => T,
subscribe: (notify: () => void) => () => void,
): Accessor<T>This can be used to integrate other systems that implement the Observable pattern (such as GObject) with Gnim's reactive system.
function bindTitle(window: Gtk.Window): Accessor<string> {
return createAccessor(
function get() {
return window.title
},
function subscribe(notify) {
const id = window.connect("notify::title", notify)
return () => window.disconnect(id)
},
)
}subscribe is only called the first time the accessor is read in a tracking scope, and the disconnect function is called once nothing tracks it anymore. While subscribed, the value is cached and only re-read when notify is called. Observers are notified only when it changed according to Object.is.
TIP
For binding GObject properties prefer using bind.
isAccessor
Checks whether a value is an Accessor. Useful for props that accept either a plain value or an accessor, see prop.
function isAccessor(value: unknown): value is Accessorconst [count] = createState(0)
isAccessor(count) // true
isAccessor(() => count()) // falseScopes and Life cycle
A scope holds cleanup functions, child scopes and context values. effect and computed create scopes of their own, so everything allocated inside them is torn down before they re-run.
createRoot(() => {
const scope = getScope()
// Inside this function scope, synchronously executed code will have access
// to `scope` through `getScope()` and will attach any allocated resources,
// such as signal subscriptions.
scopedFunction()
// At a later point it can be disposed.
scope.dispose()
})Disposing a scope disposes its child scopes first, then runs its own cleanup callbacks in reverse registration order.
createRoot
function createRoot<T>(fn: (dispose: () => void) => T, parent?: Scope | null): TCreates a root scope. You likely won't need to use it since render() will create a root scope for you.
parent defaults to the current scope, so that disposing the parent also disposes the root. Pass null for a root that is only disposed explicitly.
Example:
let state: Accessor<number>
createRoot((dispose) => {
effect(() => {
if (state() > 5) {
dispose()
}
})
})getScope
Gets the current scope. Throws when there is none. You might need to reference the scope in cases where async functions need to run in the scope.
Example:
const scope = getScope()
setTimeout(() => {
// This callback gets run without an owner scope.
// Restore owner via runScope:
runScope(scope, () => {
const foo = FooContext.use()
onCleanup(() => {
print("some cleanup")
})
})
}, 1000)runScope
function runScope<T>(scope: Scope, fn: () => T): TRuns fn with scope as the current scope, so that onCleanup, effect and contexts inside attach to it. Throws when the scope is already disposed.
onCleanup
Attaches a cleanup function to the current scope. Cleanups run untracked, in reverse registration order.
Example:
function MyComponent() {
const interval = setInterval(() => console.log("tick"), 1000)
onCleanup(() => {
clearInterval(interval)
})
return <></>
}onMount
Schedule a function to run after the current scope returns. Effects use the same mechanism for their first run. When the scope is already mounted, or there is no scope, the callback runs immediately.
Example:
function MyWindow() {
let win: Gtk.Window
onMount(() => {
win.present()
})
return <Gtk.Window ref={(self) => (win = self)} />
}NOTE
onMount can be thought of as an alias for fn => effect(() => untrack(fn))
Contexts
Context provides a form of dependency injection. It lets you avoid the need to pass data as props through intermediate components (a.k.a. prop drilling). The default value is used when no Provider is found above in the hierarchy.
Example:
const MyContext = createContext("fallback-value")
function ConsumerComponent() {
const value = MyContext.use()
return <Gtk.Label label={value} />
}
function ProviderComponent() {
return (
<MyContext value="my-value">
<ConsumerComponent />
</MyContext>
)
}Outside of JSX, provide runs a function in a new child scope where use returns the given value.
MyContext.provide("my-value", () => {
MyContext.use() // "my-value"
})