Skip to Content
Course content

203: Building UIs with Laminar

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Laminar is a bit of a departure from the way you might be used to thinking about the DOM. Instead of manually selecting elements and updating their inner text, we treat the UI as a direct reflection of state. I like to think of it as "wiring" the data to the view; once the connection is made, the data flows automatically.

To get a feel for this, let's build a simple "Focus Timer"—a basic countdown that lets you start and stop a timer. It sounds trivial, but it perfectly illustrates how Laminar handles state via Var and Signal.

Setting up the state

In Laminar, the source of truth is usually a Var. It's essentially a reactive variable. I'll start by defining the seconds remaining and whether the timer is currently running. I'm using a Var for both because these are values we intend to change based on user interaction.

import laminar._
import org.scalajs.dom

val secondsVar = Var(25 * 60) // 25 minutes
val isRunningVar = Var(false)


Sketching the timer interface

Now for the UI. Laminar uses a DSL that looks very similar to HTML. One thing I love here is that we can embed Signals directly into the tags. Since secondsVar is a Var, calling .signal on it gives us a stream of values that the UI can listen to.

val timerApp = div(
  h1("Focus Timer"),
  div(
    span(
      // We map the total seconds into a readable MM:SS format
      secondsVar.signal.map { s =>
        val mins = s / 60
        val secs = s % 60
        f"${mins}%02d:${secs}%02d"
      }
    ),
    button(
      // This is a simple observer that toggles our boolean Var
      onClick --> { isRunningVar.update(!isRunningVar.now) },
      isRunningVar.signal.map(running => if (running) "Stop" else "Start")
    )
  )
)


The "Static UI" trap

Here is where I almost tripped up when I first started with Laminar. I tried to implement the actual countdown logic like this:

// WRONG: This only runs once when the app starts!
if (isRunningVar.now) {
  dom.window.setInterval(() => {
    secondsVar.update(secondsVar.now - 1)
  }, 1000)
}


The problem? isRunningVar.now is just a snapshot of the value at the moment that line of code executes. It isn't "watching" the variable. My timer never started because the if statement executed once during initialization and then disappeared into the void. To fix this, I need to react to the change of the state, not the current value.

Wiring the clock tick

To actually make the timer move, I'll use a Signal to trigger the side effect of starting and stopping the interval. I'll store the interval ID so I can clear it when the user hits "Stop".

var intervalId: Int = 0

// We observe the isRunningVar signal
isRunningVar.signal.foreach { running =>
  if (running) {
    intervalId = dom.window.setInterval(() => {
      if (secondsVar.now > 0) {
        secondsVar.update(secondsVar.now - 1)
      }
    }, 1000)
  } else {
    dom.window.clearInterval(intervalId)
  }
}

// Finally, mount it to the body
dom.body.appendChild(timerApp.toDomNode)


Notice how the UI code remains completely separate from the timing logic. The button updates isRunningVar, the observer handles the setInterval, and the span simply reflects whatever is inside secondsVar. It's a clean, unidirectional loop.




📋 Practical Task

Exercise: Adding a "Reset" Feature to the Focus Timer

Currently, our Focus Timer can start and stop, but if the timer reaches zero or if the user wants to start over, they have to refresh the page. Your task is to extend the existing implementation.

  • Add a "Reset" button to the UI.
  • When the Reset button is clicked, it should:
    1. Set isRunningVar to false (stopping the timer).
    2. Set secondsVar back to its original value of 1500 (25 minutes).
  • Ensure the "Start/Stop" button text updates correctly when the Reset button is pressed.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.