tui4s
Guide

Components

Two styles of components, hooks and a class, and how long their state lives.

A hook component is a function that allocates its state:

CounterStyles.scala
    def counter(label: String): UI < Sync =
      for count <- Signal.initRef(0)
      yield div.style(_.border(1.px, CounterStyles.frame).padding(0.px, 1.px))(
        span(label).style(_.bold.color(CounterStyles.label)),
        span("Count: ", count.map(_.toString))
          .style(_.color(CounterStyles.count)),
        div.style(_.row.gap(1.px))(
          button("+").onClick(count.updateAndGet(_ + 1)),
          button("-").onClick(count.updateAndGet(_ - 1)),
        ),
      )

initSig(0) (import tui4s.initSig) is the short name of Signal.initRef(0), akin to Vue's ref(0). The Counter of the quick start uses it.

A class component has a State type, an initial value and a pure view:

CounterStyles.scala
/** Style 2: a component class with a type member for its state. The runner
  * allocates the state at mount.
  */
final class CounterBox(label: String) extends Component:
    type State = Int
    def initial: Int                    = 0
    // claude 2026-10-19: changed: the colours of the stylesheet (L1b)
    def view(count: SignalRef[Int]): UI =
      div.style(_.border(1.px, CounterStyles.frame).padding(0.px, 1.px))(
        span(label).style(_.bold.color(CounterStyles.label)),
        span("Count: ", count.map(_.toString))
          .style(_.color(CounterStyles.count)),
        div.style(_.row.gap(1.px))(
          button("+").onClick(count.updateAndGet(_ + 1)),
          button("-").onClick(count.updateAndGet(_ - 1)),
        ),
      )
end CounterBox

The app puts both styles in one tree. component(...) is a node that the runner mounts:

CounterStyles.scala
    def ui: UI < Sync =
      for shown <- Signal.initRef(true)
      yield div.onKeyDown {
        case KeyboardEvent(Keyboard.Char('h'), _, _) => shown.updateAndGet(!_)
        case KeyboardEvent(Keyboard.Char('q'), _, _) => Tui.quit
        case _                                       => ()
      }(
        h1("Counter styles: h hides the hook counters, q quits"),
        div.style(_.row.gap(4.px))(
          div(
            h2("Hooks"),
            when(shown)(
              div.style(_.row.gap(2.px))(
                component(CounterHooks.counter("Left")),
                component(CounterHooks.counter("Right")),
              ),
            ),
          ),
          div(
            h2("Components"),
            div.style(_.row.gap(2.px))(CounterBox("Left"), CounterBox("Right")),
          ),
        ),
      )

Hooks

A hook component returns UI < Sync. It returns UI < (Sync & Scope) when it uses a hook that starts a fiber, and UI < (Async & Scope) when its body waits.

HookWhat it gives
Signal.initRef(initial)A piece of state: a Kyo SignalRef.
useHold(initial)(stream)The last value of a stream, as a signal.
useFold(initial)(stream)(f)A stream folded into a signal.
useEffect(effect)An effect that runs while the Scope is open.
useInterval(period)(action)An action that runs again after each period.

At the top of an app, sequence the components in a for. They run once, and their effects live as long as the Scope of the app. Inside a reactive part of the tree (a render, a foreach row, a when), wrap the component in component(body). The runner runs the body when the node mounts, in a Scope that closes when the node unmounts.

The identity of a node is its call site and its key. While a node of the same identity stays at the same position, the mount goes on: its state, its effects and its UI stay, and the body does not run again. Give a key when one call site shows different things at one position: component(user.id)(...).

The class style

The runner allocates a SignalRef[State] with initial when the component mounts. It keeps the state under the position and the stateKey (the class name by default). When the parent renders again, the new instance gets the same state. onMount(state) runs once per mount, on a fiber with a Scope of its own. Override stateKey to keep one state for each value, for example one for each id.

How long the state lives

EventHook component (component)Class component
The parent renders againThe state stays. The body does not run again.The state stays. onMount does not run again.
A when hides itThe state goes. The Scope closes and its fibers stop.The state goes. The fiber of onMount stops.
It comes backIt starts fresh.It starts from initial.
displayNone hides itThe state stays.The state stays.
The run endsIts fibers stop.Its fibers stop.

In the example, h hides the hook counters. When they come back, they count from 0 again. The class counters keep their counts.

The frame of the event that mounts a hook component waits for its first UI at most TuiOptions.pendingTimeout (2 s). Then the frame comes without it, and the UI comes in a later frame.

A failure of a hook body or of onMount gives one line at error in the log of the run, with the position. The run goes on.

A kyo-ui UI that is built outside tui4s keeps no state between two builds. The mount of a node and its state are a part of the tui4s runner. In another kyo-ui runner, a component node is empty.
Copyright © 2026