Skip to content
Reactive state with flow

Reactive state with flow

The flow package holds state as typed values that tell listeners when they change. It is plain Go with generics and imports nothing from Bellui, so you can keep your state in a package that has no UI code and test it on its own.

Values and derived values

first := flow.New("Ada")
last := flow.New("Lovelace")
shout := flow.New(false)

full := flow.Derive(func() string {
	s := first.Get() + " " + last.Get()
	if shout.Get() {
		s = strings.ToUpper(s)
	}
	return s
}, first, last, shout)
  • flow.New creates a flow.Value with a starting value. The type is inferred from it and must be comparable. Get reads it and Set changes it.
  • flow.Derive creates a flow.Derived value. It runs the function once straight away, then again each time one of the listed sources changes. A derived value can itself be a source for another one.

List every value the function reads. Derive does not discover them by itself, and a source you leave out will not trigger a recompute.

Set does nothing when the new value equals the current one, and a derived value only notifies its listeners when the result actually changes. For a read-modify-write, use Modify, which does both steps under one lock:

clicks := flow.New(0)
counter := widget.NewLabel("")
widget.BindLabel(counter, widget.FormatValue(clicks, "Clicked %d times"))
button := widget.NewButton("Click", func() {
	clicks.Modify(func(n int) int { return n + 1 })
})

Connect values to widgets

The widget package has helpers that connect a flow value to a widget:

firstEntry := widget.NewEntry()
widget.BindEntry(firstEntry, first) // two-way
lastEntry := widget.NewEntry()
widget.BindEntry(lastEntry, last)
shoutCheck := widget.NewCheck("Shout", nil)
widget.BindCheck(shoutCheck, shout)

result := widget.NewLabel("")
widget.BindLabel(result, widget.StringDerived{Derived: full}) // one-way
HelperWidgetDirection
widget.BindEntryEntry and Value[string]both ways
widget.BindCheckCheck and Value[bool]both ways
widget.BindSliderSlider and Value[float64]both ways
widget.BindProgressBarProgressBar from Value[float64]value to widget
widget.BindLabelLabel from a StringSourcevalue to widget

BindLabel takes a widget.StringSource, not a flow value. Wrap the value to get one:

  • widget.StringValue{Value: v} for a *flow.Value[string],
  • widget.StringDerived{Derived: d} for a *flow.Derived[string],
  • widget.FormatValue(v, format) for any *flow.Value, formatted with fmt.Sprintf.

Each helper sets the widget from the value straight away and returns a function that disconnects them. The two-way helpers install their own OnChanged on the widget. They keep the OnChanged you set before calling them and call it too, and restore it when you disconnect.

Goroutines

flow values are safe to read and set from any goroutine. Listeners, however, run on the goroutine that called Set, straight away. When a value is connected to widgets, its listeners change those widgets, so set it on the UI goroutine. From background work, wrap the change in bellui.Do:

go func() {
	time.Sleep(3 * time.Second)
	bellui.Do(func() { // listeners update widgets, so Set on the UI goroutine
		last.Set("Byron")
	})
}()

Data binding does this for you; see which one to use.

Listen to changes

OnChange registers a function to call after each change and returns a function that unregisters it:

stop := full.OnChange(func() {
	fmt.Println("name is now", full.Get())
})
defer stop()

Unlike a binding listener, it does not run when you register it.

Value and Derived also have Listen(func(old, new T)). In this version both arguments receive the new value. If you need the previous value, keep a copy yourself in an OnChange listener.

A derived value keeps listening to its sources until you call its Close method. Call it when a derived value is no longer needed but its sources live on.

The full program:

// Reactive state with the flow package: values, derived values and the
// widget helpers that connect them.
package main

import (
	"fmt"
	"strings"
	"time"

	"github.com/belluikit/bellui"
	"github.com/belluikit/bellui/app"
	"github.com/belluikit/bellui/container"
	"github.com/belluikit/bellui/flow"
	"github.com/belluikit/bellui/widget"
)

func main() {
	a := app.New()
	w := a.NewWindow("Flow")

	first := flow.New("Ada")
	last := flow.New("Lovelace")
	shout := flow.New(false)

	full := flow.Derive(func() string {
		s := first.Get() + " " + last.Get()
		if shout.Get() {
			s = strings.ToUpper(s)
		}
		return s
	}, first, last, shout)

	firstEntry := widget.NewEntry()
	widget.BindEntry(firstEntry, first) // two-way
	lastEntry := widget.NewEntry()
	widget.BindEntry(lastEntry, last)
	shoutCheck := widget.NewCheck("Shout", nil)
	widget.BindCheck(shoutCheck, shout)

	result := widget.NewLabel("")
	widget.BindLabel(result, widget.StringDerived{Derived: full}) // one-way

	clicks := flow.New(0)
	counter := widget.NewLabel("")
	widget.BindLabel(counter, widget.FormatValue(clicks, "Clicked %d times"))
	button := widget.NewButton("Click", func() {
		clicks.Modify(func(n int) int { return n + 1 })
	})

	go func() {
		time.Sleep(3 * time.Second)
		bellui.Do(func() { // listeners update widgets, so Set on the UI goroutine
			last.Set("Byron")
		})
	}()

	stop := full.OnChange(func() {
		fmt.Println("name is now", full.Get())
	})
	defer stop()

	w.SetContent(container.NewVBox(firstEntry, lastEntry, shoutCheck, result, counter, button))
	bellui.ShowAndRun(w)
}