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
as<R = T>(fn: (value: T) => R): Accessor<R>
peek(): T
subscribe(callback: () => void): () => void
}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
To subscribe for value changes you can use the subscribe method.
const accessor: Accessor<any>
const unsubscribe = accessor.subscribe(() => {
console.log("value of accessor changed to", accessor.peek())
})
unsubscribe()WARNING
The subscribe method is not scope aware. Do not forget to clean them up when no longer needed. Alternatively, use an effect instead.
.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())createState
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)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:
const [value, setValue] = createState("initial value", {
equals: (prev, next): boolean => {
return prev != next
},
})computed
Creates a computed value that tracks dependencies and memoizes the value.
function computed<T>(compute: () => 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())untrack
An alternative to .peek(): it lets you read Accessor values without tracking them.
let value: Accessor<T>
const a = value.peek()
const b = untrack(() => value())bind
Creates an Accessor on a GObject.Object's property or a Store.
type Bindable = Store | GObject.Object
function bind<T extends Bindable, P extends PropKeys<T>>(
object: T,
property: P,
): Accessor<T[P]>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")It also supports nested bindings.
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(fn: () => void): voidExample:
const count: Accessor<number>
effect(() => {
console.log(count()) // reruns whenever count changes
})
effect(() => {
console.log(count.peek()) // only runs once
})CAUTION
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.
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.
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 are 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,
})Scopes and Life cycle
A scope is essentially a global object which holds cleanup functions and context values.
let scope = new Scope()
// Inside this function, synchronously executed code will have access
// to `scope` and will attach any allocated resources, such as signal
// subscriptions.
scopedFuntion()
// At a later point it can be disposed.
scope.dispose()createRoot
function createRoot<T>(fn: (dispose: () => void) => T, owner?: Scope)Creates a root scope. You likely won't need to use it since render() will create a root scope for you.
Example:
let state: Accessor<number>
createRoot((dipose) => {
effect(() => {
if (state() > 5) {
dispose()
}
})
})getScope
Gets the current scope. 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 scope.run:
scope.run(() => {
const foo = FooContext.use()
onCleanup(() => {
print("some cleanup")
})
})
}, 1000)onCleanup
Attaches a cleanup function to the current scope.
Example:
function MyComponent() {
const dispose = signal.subscribe(() => {})
onCleanup(() => {
dispose()
})
return <></>
}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>
)
}