Conduit Integration

Conduit Integration

Preactile integrates with conduit to provide a clean, effect-native state management pattern. Components subscribe to slices of your model and automatically re-render when relevant data changes.

Installation

Add the conduit module alongside preactile (%% is enough on a Scala.js project):

libraryDependencies ++= Seq(
  "rocks.earlyeffect" %% "preactile"         % "0.0.2",
  "rocks.earlyeffect" %% "preactile-conduit" % "0.0.2",
)

The index page shows the current published version.

Basic usage

Define a model with Optics, create a handler, then build a conduit with Conduit.make:

import conduit.*

case class Counter(count: Int) derives Optics

sealed trait CountAction extends Action
object CountActions:
  case object Increment extends CountAction
  case object Decrement extends CountAction

val counterHandler = handle[Counter, CountAction]:
  case CountActions.Increment => update(_.copy(count = _.count + 1))
  case CountActions.Decrement => update(_.copy(count = _.count - 1))

val counterConduit = Conduit.make(Counter(0))(counterHandler)

Conduit.make is the synchronous constructor. There is no Conduit.live.

Creating conduit components

Use the component extension on your conduit. The simplest form takes a render function over the whole model:

import preactile.*

val CounterView = counterConduit.component { model =>
  E.div(
    E.span("Count: "),
    E.strong(model.count.toString),
  )
}

This component subscribes to the entire model and re-renders on any change.

Using lenses for focused subscriptions

For larger models, pass a path so the component only re-renders when that slice changes:

import conduit.*
import preactile.*

case class User(name: String, age: Int) derives Optics
case class AppState(user: User, theme: String) derives Optics

val userConduit = Conduit.make(AppState(User("Alice", 30), "light"))(handler)

val UserNameView = userConduit.component(_.user.name) { name =>
  E.h2(s"Hello, $name!")
}

UserNameView only re-renders when the user's name changes, not on unrelated state updates.

Components with props and lenses

Combine external props with a lens-subscribed state:

import preactile.*

val UserProfile = userConduit.component(_.user) { (showAge: Boolean, user: User) =>
  E.div(
    E.h2(user.name),
    when(showAge)(E.p(s"Age: ${user.age}")),
  )
}

UserProfile(true)   // name and age
UserProfile(false)  // name only

Sending actions from components

Event handlers enqueue with conduit.unsafe(action) (not unsafe.send). The conduit does not apply anything until you drain the queue. In a Preactile event handler that is conduit.unsafe.run(true): drain currently queued actions (and any follow-ups) and return.

import preactile.*

val CounterControls = counterConduit.component { model =>
  E.div(
    E.button(
      A.onClick { _ =>
        counterConduit.unsafe(CountActions.Decrement)
        counterConduit.unsafe.run(true)
      },
      "-",
    ),
    E.strong(model.count.toString),
    E.button(
      A.onClick { _ =>
        counterConduit.unsafe(CountActions.Increment)
        counterConduit.unsafe.run(true)
      },
      "+",
    ),
  )
}

A long-running app (the example todo) can instead call conduit.run(false) once from @main and then only enqueue with unsafe(action).

Lifecycle

ConduitComponent manages the subscription itself: it zooms and subscribes in componentWillMount, and unsubscribes in componentWillUnmount. You do not need to override those hooks for the subscription to work.

Live demo: Todo app with conduit

A complete todo application built with preactile and conduit, rendered live in your browser. Add items, toggle completion, filter by status, double-click to edit inline, or clear completed todos.

// ---- Components ----

private val ENTER  = 13
private val ESCAPE = 27

/** Root app component โ€” subscribes to the full model via conduit.component. */
val App = todoConduit.component { model =>
  console.log(s"[TodoDemo] App render: ${model.items.size} items")
  E.div(
    styles.App,
    Header(model),
    ListArea(model),
    FooterBar(model),
  )
}

/** Header with title and add-todo input. */
object Header extends Component[TodoModel]:
  def render(model: TodoModel): VNode =
    E.header(
      styles.Header,
      S.margin.zero,
      E.div(
        S.display.flex,
        S.alignItems.center,
        S.justifyContent.spaceBetween,
        S.width.pct(100),
        E.h2(styles.Title, "โœจ Todos"),
        when(model.items.nonEmpty) {
          val activeCount = model.items.count(!_.done)
          val allDone     = activeCount == 0
          E.span(
            S.fontSize.px(12),
            S.fontWeight(500),
            S.padding("3px 8px"),
            S.borderRadius.px(99),
            S.background(if allDone then "rgba(74, 222, 128, 0.3)" else "rgba(255,255,255,0.2)"),
            S.transition("background-color 0.3s ease-in-out"),
            if allDone then "๐ŸŽ‰ All done!" else s"$activeCount active",
          )
        },
      ),
      E.input(
        styles.AddInput,
        A.placeholder("What needs to be done?"),
        A.onKeyDown { e =>
          console.log(s"[TodoDemo] keyDown: keyCode=${e.keyCode}, key=${e.key}")
          if e.keyCode == ENTER then
            val input = e.target.asInstanceOf[HTMLInputElement]
            console.log(s"[TodoDemo] Enter pressed, value='${input.value}'")
            if input.value.nonEmpty then
              todoConduit.unsafe(TodoActions.Add(input.value.trim))
              todoConduit.unsafe.run(true)
              input.value = ""
        },
      ),
    )
end Header

/** List area with toggle-all checkbox and items. */
object ListArea extends Component[TodoModel]:
  def render(model: TodoModel): VNode =
    val filtered = model.items.filter(matchFilter(_, model.filter))
    E.section(
      S.margin.zero,
      if filtered.isEmpty then
        E.div(
          styles.EmptyState,
          E.span(S.fontSize.px(28), S.display.block, S.marginBottom.px(8), "๐Ÿ“"),
          E.div(S.fontWeight(500), S.color("#6b7280"), S.marginBottom.px(4), "No todos yet"),
          E.div(S.fontSize.px(13), "Add one above to get started!"),
        )
      else
        fragment(
          ToggleAllRow(model),
          E.ul(
            styles.ListArea,
            filtered.map(item => Item(item).withKey(item.key)).toList,
          ),
        ),
    )
  end render

  private def matchFilter(todo: Todo, filter: Filter): Boolean =
    (filter, todo.done) match
      case (_, true) if filter == Filter.Active     => false
      case (_, false) if filter == Filter.Completed => false
      case _                                        => true
end ListArea

/** Toggle-all checkbox row. */
object ToggleAllRow extends Component[TodoModel]:
  def render(model: TodoModel): VNode =
    val allDone = model.items.nonEmpty && model.items.forall(_.done)
    E.div(
      styles.ToggleAllArea,
      E.input(
        styles.ToggleAllCheckbox,
        A.`type`("checkbox"),
        A.checked(allDone),
        A.onChange { _ =>
          if model.items.exists(!_.done) then
            todoConduit.unsafe(TodoActions.SetAllDone(true))
            todoConduit.unsafe.run(true)
          else
            todoConduit.unsafe(TodoActions.SetAllDone(false))
            todoConduit.unsafe.run(true)
        },
      ),
      "Mark all complete",
    )
  end render
end ToggleAllRow

/** Individual todo item โ€” renders either viewer or editor based on editing state. */
object Item extends Component[Todo]:
  def render(todo: Todo): VNode =
    E.li(
      styles.ItemRow,
      styles.ItemRowHover,
      styles.DoneRow.when(todo.done),
      styles.EnterAnim,
      if todo.editing then Editor(todo) else Viewer(todo),
    )

/** View mode: checkbox + text label + delete button. */
object Viewer extends Component[Todo]:
  def render(todo: Todo): VNode =
    E.div(
      S.display.flex,
      S.alignItems.center,
      S.width.pct(100),
      E.input(
        styles.ToggleCheckbox,
        A.`type`("checkbox"),
        A.checked(todo.done),
        A.onChange { _ =>
          todoConduit.unsafe(TodoActions.Toggle(todo.key))
          todoConduit.unsafe.run(true)
        },
      ),
      E.span(
        styles.ItemText,
        styles.DoneText.when(todo.done),
        styles.CompleteAnim.when(todo.done),
        todo.text,
        A.onDoubleClick { _ =>
          todoConduit.unsafe(TodoActions.StartEditing(todo.key))
          todoConduit.unsafe.run(true)
        },
      ),
      E.button(
        styles.DeleteBtn,
        A.`class`("todo-delete-btn"), // class name used by ItemRowHover selector
        A.onClick { _ =>
          todoConduit.unsafe(TodoActions.Delete(todo.key))
          todoConduit.unsafe.run(true)
        },
        "โœ•",
      ),
    )
end Viewer

/** Edit mode: inline text input with Enter/Escape handling. */
object Editor extends StatefulComponent[Todo, String]:
  override def initialState(todo: Todo): String = todo.text

  def render(props: Todo, state: String, instance: Instance): VNode =
    E.div(
      S.display.flex,
      S.alignItems.center,
      // Spacer to align with checkbox position.
      E.span(S.width.px(18), S.marginRight.px(10)),
      E.input(
        styles.EditInput,
        A.value(state),
        A.onKeyDown { e =>
          e.keyCode match
            case ENTER =>
              todoConduit.unsafe(TodoActions.FinishEditing(props.key, state))
              todoConduit.unsafe.run(true)
            case ESCAPE =>
              todoConduit.unsafe(TodoActions.CancelEditing(props.key))
              todoConduit.unsafe.run(true)
            case _ => ()
        },
        A.onKeyUp(_ => instance.setState(instance.base.asInstanceOf[HTMLInputElement].value)),
        A.onBlur { _ =>
          todoConduit.unsafe(TodoActions.FinishEditing(props.key, state))
          todoConduit.unsafe.run(true)
        },
      ).withRef(x => setTimeout(1)(x.asInstanceOf[HTMLInputElement].focus())),
    )
end Editor

/** Footer bar with item count, clear button, and filter buttons. */
object FooterBar extends Component[TodoModel]:
  def render(model: TodoModel): VNode =
    val activeCount  = model.items.count(!_.done)
    val hasCompleted = model.items.exists(_.done)

    E.footer(
      styles.Footer,
      S.margin.zero,
      // Left side: count + clear completed (with subtle pop on change).
      E.div(
        styles.FooterLeft,
        E.span(
          S.display.inlineBlock,
          S.transition("transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1)"),
          s"$activeCount item${if activeCount != 1 then "s" else ""} left",
        ),
        when(hasCompleted) {
          E.button(
            styles.ClearBtn,
            A.onClick { _ =>
              todoConduit.unsafe(TodoActions.ClearCompleted)
              todoConduit.unsafe.run(true)
            },
            "Clear completed",
          )
        },
      ),
      // Right side: filter buttons.
      E.div(styles.Filters, FilterButton(Filter.All), FilterButton(Filter.Active), FilterButton(Filter.Completed)),
    )
  end render
end FooterBar

/** Filter button component โ€” uses a lens to subscribe only to the filter field. */
def FilterButton(target: Filter): VNode =
  todoConduit
    .component(_.filter) { current =>
      val active = current == target
      E.button(
        styles.FilterBtn,
        styles.FilterBtnActive.when(active),
        A.onClick { _ =>
          todoConduit.unsafe(TodoActions.SetFilter(target))
          todoConduit.unsafe.run(true)
        },
        target.getClass.getSimpleName.init, // "All", "Active", "Completed"
      )
    }
    .apply(())

This example runs in your browser; enable JavaScript to see it.

How it works

The demo above uses several conduit patterns.

Model and handler โ€” A TodoModel with items and a filter derives Optics. The handler processes actions by pattern matching. The conduit is Conduit.make:

case class Todo(key: String, text: String, done: Boolean) derives Optics

sealed trait Filter extends Product with Serializable
object Filter:
  case object All       extends Filter
  case object Active    extends Filter
  case object Completed extends Filter

case class TodoModel(items: List[Todo], filter: Filter) derives Optics

sealed trait TodoAction extends Action
object TodoActions:
  final case class Add(text: String)    extends TodoAction
  final case class Toggle(key: String)  extends TodoAction
  final case class Delete(key: String)  extends TodoAction
  final case class SetFilter(f: Filter) extends TodoAction
  case object ClearCompleted            extends TodoAction

val todoHandler = handle[TodoModel, TodoAction]:
  case TodoActions.Add(text) => update(m => m.copy(items = m.items :+ Todo(text)))
  case TodoActions.Toggle(key) =>
    update(m => m.copy(items = m.items.map(t => if t.key == key then t.copy(done = !t.done) else t)))
  // ... other cases

val todoConduit = Conduit.make(TodoModel(Nil, Filter.All))(todoHandler)

Conduit components โ€” The root App is todoConduit.component { model => โ€ฆ } (full-model subscription). Filter buttons use a path (_.filter) so they only re-render when the filter changes:

val App = todoConduit.component { model =>
  E.div(styles.App, Header(model), ListArea(model), FooterBar(model))
}

def FilterButton(target: Filter): VNode =
  todoConduit
    .component(_.filter) { current =>
      val active = current == target
      E.button(
        styles.FilterBtn,
        styles.FilterBtnActive.when(active),
        A.onClick { _ =>
          todoConduit.unsafe(TodoActions.SetFilter(target))
          todoConduit.unsafe.run(true)
        },
        target.toString,
      )
    }
    .apply(())

Sending actions โ€” Event handlers enqueue, then drain:

A.onChange { _ =>
  todoConduit.unsafe(TodoActions.Toggle(todo.key))
  todoConduit.unsafe.run(true)
}

if e.keyCode == ENTER && input.value.nonEmpty then
  todoConduit.unsafe(TodoActions.Add(input.value.trim))
  todoConduit.unsafe.run(true)
  input.value = ""

Styling โ€” All of the demo styling is CssClass plus S.*. No external stylesheet:

object styles:
  import S.*

  object App extends CssClass(
    fontFamily("system-ui, -apple-system, sans-serif"),
    borderRadius.px(8),
    border("1px solid #e5e7eb"),
  )

  object DoneText extends CssClass(
    S("text-decoration", "line-through"),
    color("#9ca3af"),
  )

See the Examples page for the other live demos. Live host exports are on Embedding in React and Embedding in Preact.