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.