Reactivity

Assign to a view property and the UI updates. There is no state container, no observer registration and no manual refresh.

import nib

def main(app: nib.App):
    status = nib.Text("Idle", foreground_color=nib.Color.SECONDARY)
    progress = nib.ProgressView(value=0.0)

    def start():
        status.content = "Running"
        status.foreground_color = nib.Color.BLUE
        progress.value = 0.5

    app.build(
        nib.VStack(
            controls=[status, progress, nib.Button("Start", action=start)],
            spacing=12,
            padding=16,
        )
    )

nib.run(main)

What happens on assignment

  1. The property setter stores the value and asks the app to render. Setting a property to the value it already has does nothing.
  2. The render loop thread serialises the tree into flat nodes and compares each node's packed props and modifiers with the previous frame.
  3. Changed nodes go out as a patch; a structural change (a child added, removed, reordered or replaced by another type) goes out as a full render.
  4. The runtime applies the patch to the node and SwiftUI re-evaluates only the affected path.

The three assignments in start() above arrive as one frame: callbacks run inside a batch, and changes made outside callbacks coalesce in the render loop.

What is reactive

Every constructor argument that has a matching property. That covers content (text.content, slider.value, toggle.is_on, image.src, stack.controls), visible, and every modifier (opacity, background, padding, font, animation, ...). The reference lists the properties of each view.

Two things are deliberately not reactive:

  • Private attributes. Writing view._content stores a value and nothing else; the runtime never hears about it.
  • Unknown attributes. text.colour = "red" is accepted by Python but has no effect; Nib logs a warning the first time so the typo is visible.

Mutating a value in place is invisible too. Assign a new list rather than appending to the old one, or call view.update() (or app.update()) afterwards:

rows.controls = rows.controls + [nib.Text("new row")]   # reactive
rows.controls.append(nib.Text("new row")); rows.update()  # also fine

Controls stay in sync

A TextField, TextEditor, Toggle, Slider, Picker, DisclosureGroup or Map keeps its value up to date as the user interacts with it: reading field.value inside a callback gives what the user typed, with no round trip through the runtime. Assigning the property is a programmatic set and is sent to the runtime. A stale value that Python already knows is never pushed back over what the user is typing.

Threads and long work

Callbacks run one at a time on the event thread. Keep them short: a slow callback delays every later event. Do long work on your own thread and set properties from there; property writes are safe from any thread.

import threading, time

def refresh():
    status.content = "Loading"

    def work():
        time.sleep(2)  # network, disk, ...
        if app.is_running:
            status.content = "Done"

    threading.Thread(target=work, daemon=True).start()

app.is_running turns False when the app quits or nib run replaces it after a reload; app.on_quit fires in both cases. Threads you start should check one or the other.

Class-based apps

With class MyApp(nib.App) the body() method is called on every render and builds fresh view instances, so state cannot live in those instances. Keep it in nib.State attributes: assigning one requests a render.

class Counter(nib.App):
    count = nib.State(0)

    def body(self) -> nib.View:
        return nib.Button(f"Count: {self.count}", action=self.increment)

    def increment(self):
        self.count += 1

nib.Binding is a small two-way accessor (binding.value) built from a getter and a setter; views take plain values, so wire a binding through on_change callbacks when you need one.