Hooks
Hooks keep state and run code across builds, with the names and semantics of
React’s. A component is a struct whose Build method returns a
tela.Node, or a function wrapped in tela.C or tela.CP; it may call hooks. The
sections below follow the order you usually need them.
The rules
Section titled “The rules”Hooks follow React’s rules: call them at the top level of a component’s
Build method or function, in the same order on every build, never inside
conditions or loops. A hook called outside a component build panics with an
explanation (a function component called where a node is expected,
telatest.Mount(t, counter()), is told to write tela.C(counter), and
Card{}.Build() to pass Card{} itself). In dev mode, on in every
development build and test (see Getting Started), a change of the hook
order panics with both call sites, and so does a hook called from a function
that is neither the component being built (its Build, its function, or a
function literal inside them) nor a custom hook named UseXxx or useXxx.
Everything runs on the UI thread except the methods of tela.Setter,
tela.Dispatcher and tela.Value, and tela.Post, which are safe from any
goroutine.
Components
Section titled “Components”A component is a struct with a Build method (a value receiver) that
returns a node: a tela.Component. Its fields are its props, and its
children are an ordinary field of type tela.Component (several of them
through Fragment):
type Card struct { Title string OnOpen tela.Callback[func()] Children tela.Component}
func (c Card) Build() tela.Node { open, setOpen := tela.UseState(false) return Div(Class("rounded-lg border p-4"), Button(OnClick(func(tela.MouseEvent) { setOpen.Set(!open) }), Text(c.Title)), If(open, c.Children))}
func Screen() tela.Node { return Div( Card{Title: "Unread", Children: Fragment(Text("2 new"), Span(Text("from Ada")))}, Card{Title: "Archive", Children: Text("empty")}, )}- Every component value given to an element is a component boundary: an
element with its own hook state that rebuilds on its own. Its identity
among its siblings is its type and its key, so at one position a value of
the same type keeps its state and a value of another type mounts anew.
tela.WithKey(Card{...}, id)keys it (and turns it into atela.Node, forMap). - When the parent builds again with an equal value, and neither the
component’s own state nor a context it reads changed, its build is skipped
(automatic skipping, as in Jetpack Compose). The comparison is
==, field by field, and never panics: a field that cannot be compared (a slice, a map, a func, or an interface holding one) makes it build every time. A node inChildrencompares by identity, so a freshDiv(...)is a change; pass atela.Callbackrather than a func to keep it skippable. - In dev mode, a pointer (
Div(&Card{})) panics, and so does a prop in a field of typetela.Component(Card{Children: Class("p-4")}): Go cannot tell it from a component at compile time. A field meant to take props too is declared astela.Child. Iftakes a struct component directly,If(loading, Spinner{}), and renders nothing when the condition is false. It returns atela.Child, not atela.Node: a function component returnsFragment(If(...)).- A function returning a node is a component when you wrap it. Write the
wrap once, where the component is defined: the function that does the work
is private, and the public one returns its
tela.Cortela.CPnode, so nobody who uses the component writestela.C:
func Header() tela.Node { return tela.C(header) }
func header() tela.Node { return H1(Text("Inbox")) }
type BadgeProps struct{ Count int }
func Badge(p BadgeProps) tela.Node { return tela.CP(badge, p) }
func badge(p BadgeProps) tela.Node { return Span(Class("rounded-full bg-indigo-500 px-2 text-white"), Textf("%d", p.Count))}
func Toolbar() tela.Node { return Div(Header(), Badge(BadgeProps{Count: 3}))}tela.Cwraps a function without props. It builds whenever its parent builds.tela.CPwraps a function with comparable props. When the parent builds again with props==to the previous ones, and neither the component’s own state nor a context it reads changed, the build is skipped (automatic skipping, as in Jetpack Compose). Pass a named function and put everything it uses in the props; dev mode reports anonymous functions.tela.Memoistela.CPfor props that are not comparable: you give the comparison function.C,CPandMemotake an optional last argument, the keyKey(id), for the items of a list:tela.CP(badge, p, Key(p.ID))istela.WithKey(tela.CP(badge, p), p.ID). Anything else butKeypanics. A public wrapper likeBadgedoes not take a key: key where you use it,tela.WithKey(Badge(p), p.ID)(orMapKeyed).- Calling the private function directly,
header(), runs it inside the caller. That is fine for a function without hooks (a wrapper that styles its children and passes them on withFragment), but the hooks of a function called this way would take the caller’s slots: dev mode panics and says to wrap it intela.Cortela.CP, which the public function already does. The same goes forCard{}.Build(): passCard{}as a child instead (orFragment(Card{})where atela.Nodeis expected). - A custom hook is a function named
UseXxxoruseXxxthat calls hooks for the component that calls it. A struct oftela.Valuepointers is a good return value: stable, comparable, and each reader redoes only what it read.
type Drag struct{ X, Y *tela.Value[float32] }
func UseDrag() Drag { return Drag{X: tela.UseValue(float32(0)), Y: tela.UseValue(float32(0))}}tela.WithKeykeys a component or fragment, which cannot take theKeyprop. Both take a comparable key: a slice or a map does not compile.
State: UseState
Section titled “State: UseState”tela.UseState returns the current state and a tela.Setter. The first build
stores the initial value; later builds return the latest one.
func Clicks() tela.Node { count, setCount := tela.UseState(0)
return Button( OnClick(func(tela.MouseEvent) { setCount.Set(count + 1) }), Textf("Clicked %d times", count), )}Set(v) replaces the state. Update(f) computes it from the state left by the
previous queued update, which is what you want when several updates may be
queued before the next build (events in one frame, goroutines):
func Counter() tela.Node { count, setCount := tela.UseState(0) add := func(tela.MouseEvent) { setCount.Update(func(n int) int { return n + 1 }) } return Button(OnClick(add), Textf("%d", count))}For comparable types, a new state == to the current one does not rebuild. A
tela.Setter is a small comparable value, the same on every build, so it can be
stored, passed to goroutines, and put in the props of a tela.CP component.
tela.UseStateSet and tela.UseStateUpdate return the setter’s Set and
Update methods as plain functions when you only need one of them. They
exist for React’s shape, a setter that is a function: to pass it where a
func(T) is expected without wrapping it, and to keep code ported from React
reading the same. Funcs are not comparable in Go, so in the props of a
tela.CP component pass the Setter instead:
func NameField() tela.Node { name, setName := tela.UseStateSet("") return Input(Value(name), OnChange(func(e tela.ChangeEvent) { setName(e.Value) }))}tela.Toggle(setOpen) returns a func() that flips a bool state with
Update: OnClick(tela.Toggle(setOpen)). Two clicks that land in one frame
both count, which setOpen.Set(!open) loses (both read the same open).
State machines: UseReducer
Section titled “State machines: UseReducer”tela.UseReducer takes a pure reducer and returns the state with a
tela.Dispatcher. Queued actions apply in order on the UI thread.
type action struct{ kind string }
func reduce(n int, a action) int { switch a.kind { case "inc": return n + 1 case "reset": return 0 } return n}
func Stepper() tela.Node { count, dispatch := tela.UseReducer(reduce, 0) return Div( Textf("%d", count), Button(OnClick(func(tela.MouseEvent) { dispatch.Dispatch(action{"inc"}) }), Text("+")), Button(OnClick(func(tela.MouseEvent) { dispatch.Dispatch(action{"reset"}) }), Text("Reset")), )}Effects: UseEffect, UseMount and UseLayoutEffect
Section titled “Effects: UseEffect, UseMount and UseLayoutEffect”tela.UseEffect runs a function after the frame is presented, when its
dependencies changed since the previous build. The function may return a
cleanup, which runs before the effect runs again and when the component
unmounts. The dependencies follow React:
tela.UseEffect(f) // after every buildtela.UseEffect(f, id, n) // when id or n changed (compared with ==)tela.UseEffect(f, tela.Once) // only after the first build (React's [])tela.UseMount(f) // the sametela.UseMount is the idiom for subscriptions: the effect runs on mount and its
cleanup on unmount. tela.Once is the “mount only” dependency of every hook
that takes dependencies (UseLayoutEffect, UseGo, UseEffectDo,
UseMemo). UseEffect(f, nil) is rejected: React reads a null list as
“after every render”, so dev mode panics with the fix (tela.Once,
UseMount, UseOnce); outside dev mode nil keeps meaning “mount only”.
func Status(store *Store) tela.Node { online, setOnline := tela.UseState(store.Online())
tela.UseMount(func() func() { return store.Subscribe(func(on bool) { setOnline.Set(on) }) // returns the unsubscribe })
label := "Offline" if online { label = "Online" } return Span(Text(label))}
type Store struct{}
func (*Store) Online() bool { return true }func (*Store) Subscribe(func(bool)) (stop func()) { return func() {} }Two variants save the boilerplate of common effects:
tela.UseEffectDo takes an effect without a cleanup, and
tela.UseUpdateEffect skips the first build, to react to changes only:
func ThemeSaver(theme string, save func(string)) tela.Node { tela.UseEffectDo(func() { fmt.Println("the theme is", theme) }, theme) tela.UseUpdateEffect(func() func() { save(theme) // not on mount: only when the theme changes return nil }, theme) return Text(theme)}Dependencies that cannot be compared (functions, slices, maps) count as changed on every build, and dev mode says so: pass an ID or a comparable value instead. Within a frame, the effects of children run before their parents’, and effects of a component whose build was skipped do not run.
tela.UseLayoutEffect has the same shape but runs after layout and before
paint, for code that needs the geometry.
Periodic and background work: UseInterval, UseTimeout and UseGo
Section titled “Periodic and background work: UseInterval, UseTimeout and UseGo”tela.UseInterval calls a function on the UI thread every period, until the
component unmounts; a period of 0 or less pauses it, and a new period restarts
it. The function is always the latest build’s, so it sees the latest state.
It runs on the window’s frame clock, with no goroutine, so telatest’s
App.Advance moves it:
func Clock() tela.Node { now, setNow := tela.UseState(time.Now()) tela.UseInterval(time.Second, func() { setNow.Set(time.Now()) }) return Span(Class("tabular-nums"), Text(now.Format("15:04:05")))}tela.UseTimeout is the one-shot sibling: it calls a function once, a
delay after the frame that first shows the component, on the same frame clock.
A delay of 0 or less cancels it, and a change of the delay or of the
dependencies (compared like UseEffect’s) restarts it, even after it fired;
with the same delay and no dependencies it fires once per mount. The function
is the latest build’s, and unmounting cancels it:
func Notice(message string) tela.Node { visible, setVisible := tela.UseState(true) // Hide the notice 3 s after it appears, again when the message changes. tela.UseTimeout(3*time.Second, func() { setVisible.Set(false) }, message) return If(visible, P(Text(message)))}tela.UseGo runs a function on its own goroutine after the frame, like an effect
with dependencies, and cancels its context.Context when the dependencies
change or the component unmounts; the next run starts after that. The function
must return soon after the context is done, and change the UI only through
what is safe from any goroutine (a Setter, a Value, tela.Post):
func Results(query string, search func(context.Context, string) []string) tela.Node { found, setFound := tela.UseState([]string(nil)) tela.UseGo(func(ctx context.Context) { r := search(ctx, query) if ctx.Err() == nil { // a newer query did not cancel this one setFound.Set(r) } }, query) return Div(Map(found, func(s string) tela.Node { return P(Key(s), Text(s)) }))}With no dependencies, UseGo restarts its goroutine after every build, as
UseEffect runs after every build.
Memoization: UseMemo, UseOnce, UseCallback, UseRef
Section titled “Memoization: UseMemo, UseOnce, UseCallback, UseRef”tela.UseMemo caches a computation until its dependencies change, and
tela.UseOnce computes a value on the first build and keeps it (a client, a
cache, the state of a custom hook):
type Todo struct { ID int Text string}
func Filtered(todos []Todo, query string) tela.Node { visible := tela.UseMemo(func() []Todo { var out []Todo for _, t := range todos { if strings.Contains(strings.ToLower(t.Text), strings.ToLower(query)) { out = append(out, t) } } return out }, len(todos), query)
return Map(visible, func(t Todo) tela.Node { return Div(Key(t.ID), Text(t.Text)) })}tela.UseCallback returns a tela.Callback: one stable function, the same on
every build, that calls the latest function you gave it. Because a Callback is
comparable, it can travel in the props of a tela.CP component without
defeating its skipping. Call cb.Fn() to get the function:
type ToolbarProps struct { OnSave tela.Callback[func(tela.MouseEvent)]}
func Toolbar(p ToolbarProps) tela.Node { return tela.CP(toolbar, p) }
func toolbar(p ToolbarProps) tela.Node { return Button(OnClick(p.OnSave.Fn()), Text("Save"))}
func Editor() tela.Node { draft, setDraft := tela.UseState("") onSave := tela.UseCallback(func(tela.MouseEvent) { save(draft) }) return Div( Input(Bind(draft, setDraft)), Toolbar(ToolbarProps{OnSave: onSave}), // does not rebuild while typing )}
func save(string) {}tela.UseRef returns a tela.Ref, a box whose Current field persists across
builds. Changing it does not rebuild: use it for values that rendering does not
show, such as a timer or the previous value of a prop.
func RenderCount() tela.Node { renders := tela.UseRef(0) renders.Current++ return Textf("built %d times", renders.Current)}Context
Section titled “Context”tela.CreateContext makes a context with a default value; create contexts once,
at package level. Provider(value, children...) gives the value to a subtree
and tela.UseContext reads the nearest one. When the provided value changes,
only the components that read it rebuild, even if the components between skip
their build.
var Theme = tela.CreateContext("light")
func App() tela.Node { theme, setTheme := tela.UseState("dark") return Theme.Provider(theme, Page(), Button(OnClick(func(tela.MouseEvent) { setTheme.Set("light") }), Text("Light")), )}
func Page() tela.Node { return tela.C(page) }
func page() tela.Node { theme := tela.UseContext(Theme) return P(Textf("The theme is %s", theme))}A component that reads a context with no Provider above it gets the default.
When the default would panic if used (it is nil, or holds a nil function,
pointer, map or channel, or a zero Setter or Callback), dev mode warns
once per context. Provide the context above every reader, give
CreateContext a default that works, or check with IsZero, which
tela.Setter and tela.Callback have for this (and for optional callbacks in
props).
Identity: UseID
Section titled “Identity: UseID”tela.UseID returns a string that is unique in the process and the same on every
build (and the same for the same mount order, so snapshots are deterministic).
Use it to relate elements, such as a label and its field:
func Field(label string) tela.Node { id := tela.UseID() return Div( Label(For(id), Text(label)), Input(ID(id)), )}Handles on elements: UseElementRef
Section titled “Handles on elements: UseElementRef”tela.UseElementRef returns an tela.ElementRef. Attach it with Ref and act
on the element from handlers and effects: focus it, scroll it, read its bounds,
read or select the text of a field.
func Search() tela.Node { input := tela.UseElementRef()
tela.UseMount(func() func() { input.Focus() return nil })
return Input(Ref(input), Type("search"), Placeholder("Search"), Class("rounded-md border px-2 py-1"))}Its methods are: ElementRef.Focus, ElementRef.Blur,
ElementRef.Focused, ElementRef.Click (HTML’s element.click(): it runs the
activation behavior too, so a Button in a Form submits it), ElementRef.Bounds, the scroll methods
ElementRef.ScrollTo, ElementRef.ScrollBy,
ElementRef.ScrollOffset, ElementRef.ScrollIntoView and
ElementRef.ScrollToIndex, and, for text fields, ElementRef.Value,
ElementRef.Select, ElementRef.SetSelectionRange and
ElementRef.SelectionRange. The scroll methods are explained in
Scrolling and VirtualList and the text ones in
Forms and Text Input.
Observable values: UseValue
Section titled “Observable values: UseValue”Some state changes every frame: a drag offset, a scroll position, download
progress. Rebuilding a component 60 times a second for that is wasteful.
tela.UseValue returns a tela.Value, an observable cell, and where you read
it decides what a change redoes:
v.Get() called in… |
A change of v redoes… |
|---|---|
| the component’s body | the component’s build, like UseState |
a layout closure (tela.WidthFn, tela.HeightFn) |
that element’s layout only |
a paint closure (tela.TranslateFn, tela.OpacityFn, tela.BgFn, tela.TextColorFn) |
that element’s paint only |
| an event handler, effect or goroutine | nothing; Get just returns the value |
tela.MapValue(v, f) returns func() U that applies f to the value
when the closure runs, for the deferred props, and h.Scale(v, 36) is its
float32 shorthand: Style(tela.HeightFn(Scale(open, 36))).
A progress bar fed by a goroutine, which lays out the bar again without
building Download:
func Download(url string) tela.Node { progress := tela.UseValue(0.0) // from 0 to 1
tela.UseGo(func(ctx context.Context) { fetch(ctx, func(done float64) { progress.Set(done) }) // any goroutine }, url)
return Div(Class("h-2 w-64 overflow-hidden rounded-full bg-slate-200"), Div(Class("h-full bg-indigo-500"), Style(tela.WidthFn(func() float32 { return float32(256 * progress.Get()) }))))}
func fetch(ctx context.Context, report func(float64)) { for i := 0; i <= 10 && ctx.Err() == nil; i++ { report(float64(i) / 10) time.Sleep(100 * time.Millisecond) }}Set and Update are safe from any goroutine and queue the change, in order
with state updates. Get returns the value the UI thread applied last; use
Value.Latest in a handler that has just called Set and wants the new
value. Dev mode warns, once per call site, about a Get outside a build or a
deferred closure that misses queued changes. A tela.Value is comparable (the same pointer on every build), so it can
go in the props of a tela.CP component or through a context.
Text can be deferred the same way with TextFn, and a field can be bound to a
Value[string] with BindValue.
Scroll offsets and bounds
Section titled “Scroll offsets and bounds”tela.UseScroll gives two Values that follow the scroll offsets of an element,
and tela.UseBounds a Value of its tela.Rect in the window (a ResizeObserver
that also sees position):
func FadingHeader(rows tela.Node) tela.Node { list := tela.UseElementRef() _, y := tela.UseScroll(list)
header := Div(Class("h-16 bg-white shadow"), Style(tela.OpacityFn(func() float32 { return 1 - min(y.Get()/200, 1) // repaints only }))) return Div(Class("flex h-screen flex-col"), header, Div(Ref(list), Class("grow overflow-y-auto"), rows))}Animation: UseSpring, UseTween, Animate and UsePresence
Section titled “Animation: UseSpring, UseTween, Animate and UsePresence”Animations are Values that move on the frame clock, so they cost what a
UseValue read costs: a TranslateFn read only repaints, a WidthFn read
only lays out. tela.UseSpring and
tela.UseTween follow a target that can change on every
build (a new target restarts the motion from where it is; a spring keeps its
velocity), and tela.Animate moves any Value from a handler,
an effect or a goroutine. They start at their target unless the Initial option
gives a start value (tela.UseSpring(target, tela.Initial(float32(0)))).
Options such as Stiffness, Duration, Ease,
Velocity and OnDone tune them, tela.UsePresence
lets a component run its own exit inside an h.Presence, and
tela.UsePresenceTween is the whole enter-and-exit
tween as one Value from 0 to 1 and back:
func Drawer(open bool) tela.Node { target := float32(-320) // off screen to the left if open { target = 0 } x := tela.UseSpring(target) // starts at the target, then follows open return Div(Class("fixed inset-y-0 left-0 w-80 bg-white shadow-xl"), Style(tela.TranslateFn(func() (float32, float32) { return x.Get(), 0 })), // repaints only Text("Menu"))}The whole picture, with transitions, enter and exit and layout animations, is in Animation.
Environment: UseColorScheme, UseReducedMotion and UseDirection
Section titled “Environment: UseColorScheme, UseReducedMotion and UseDirection”tela.UseColorScheme returns the effective color scheme, tela.Light or
tela.Dark (the app’s choice or the system’s preference), and
tela.UseReducedMotion whether the system asks for reduced motion. The component
builds again when they change; nothing else does. Classes do not need them
(dark: and motion-reduce: follow the same settings); use them for what
classes cannot do, such as picking an image.
tela.SetColorScheme changes the app’s scheme from any goroutine; see
Styling.
tela.UseDirection returns the writing direction at the component’s position,
"ltr" or "rtl": the Dir of the nearest element above it that has one
("auto" resolved from its text), else "ltr". The component builds again
when it changes. Classes do not need it (the logical classes and rtl:
follow Dir); use it for what they cannot do, such as which arrow key goes to
the next item:
func Pager() tela.Node { page, setPage := tela.UseState(1) next, prev := tela.KeyArrowRight, tela.KeyArrowLeft if tela.UseDirection() == "rtl" { next, prev = prev, next } return Div(TabIndex(0), OnKeyDown(func(e tela.KeyboardEvent) { switch e.Key { case next: setPage.Update(func(p int) int { return p + 1 }) case prev: setPage.Update(func(p int) int { return max(p-1, 1) }) } }), Textf("Page %d", page))}Background work: Post
Section titled “Background work: Post”tela.Post runs a function on the UI thread, in order with state updates. It is
the way to touch UI-thread state, such as a tela.Ref, from a goroutine:
func load(cache *tela.Ref[[]byte]) { go func() { data := []byte("downloaded") tela.Post(func() { cache.Current = data }) }()}Functions posted before tela.Run starts run once the window is up. In tests,
telatest.Mount delivers them.
See also
Section titled “See also”- The API reference of every hook: API-Reference-tela.
- Testing shows how to assert on state without a window.