Building a Real-time Chat with Datastar
This guide walks through building a real-time multi-client chat application using ZIO HTTP and Datastar. The application demonstrates several powerful patterns for building reactive web applications with server-driven UI updates.
What We're Building
A fully functional chat application where:
- Multiple users can join and chat simultaneously
- Messages appear in real-time across all connected clients
- No page refreshes required - updates stream via Server-Sent Events (SSE)
- Clean, reactive UI with Datastar signal bindings
Key Concepts Demonstrated
- ZIO Hub for broadcasting messages to multiple subscribers
- Server-Sent Events (SSE) for real-time updates via
events { handler {...} } - Datastar signals for reactive form bindings
- Type-safe request handling with
readSignals[T] - HTML templating with the
template2DSL
Prerequisites
Add the Datastar SDK dependency to your project:
libraryDependencies += "dev.zio" %% "zio-http-datastar-sdk" % "3.7.4"
Architecture Overview
The chat application consists of four components:
- ChatMessage - Immutable message model with ZIO Schema
- ChatRoom - In-memory state using
Ref+ message broadcasting viaHub - MessageRequest - Request model for signal binding
- ChatServer - HTTP routes and HTML template
┌─────────────────┐ POST /chat/send ┌─────────────────┐
│ Browser 1 │ ───────────────────────► │ │
│ (Datastar) │ │ ChatServer │
│ │ ◄─────────────────────── │ │
└─────────────────┘ SSE: new messages │ ┌─────────┐ │
│ │ ChatRoom│ │
┌─────────────────┐ POST /chat/send │ │ (Hub) │ │
│ Browser 2 │ ───────────────────────► │ └─────────┘ │
│ (Datastar) │ │ │
│ │ ◄─────────────────────── │ │
└─────────────────┘ SSE: new messages └─────────────────┘
Implementation
1. Message Model
The ChatMessage case class represents a chat message with automatic ID and timestamp generation:
package example.datastar.chat
import zio.schema._
case class ChatMessage(
id: String,
username: String,
content: String,
timestamp: Long,
)
object ChatMessage {
def apply(username: String, content: String): ChatMessage =
ChatMessage(java.util.UUID.randomUUID().toString, username, content, System.currentTimeMillis())
implicit val schema: Schema[ChatMessage] = DeriveSchema.gen[ChatMessage]
}
Key points:
- Uses ZIO Schema for type-safe serialization
- Factory method generates UUID and timestamp automatically
- Scala 3
givensyntax for Schema derivation
2. Request Model
The MessageRequest captures the form data sent when a user submits a message:
package example.datastar.chat
import zio.schema._
case class MessageRequest(username: String, message: String)
object MessageRequest {
implicit val schema: Schema[MessageRequest] = DeriveSchema.gen[MessageRequest]
}
This model maps directly to the Datastar signals $username and $message defined in the HTML template.
3. Chat Room with Hub
The ChatRoom manages message state and broadcasts new messages to all connected clients:
package example.datastar.chat
import zio._
import zio.stream._
case class ChatRoom(
messages: Ref[List[ChatMessage]],
subscribers: Hub[ChatMessage],
)
object ChatRoom {
def make: ZIO[Any, Nothing, ChatRoom] =
for {
messages <- Ref.make(List.empty[ChatMessage])
hub <- Hub.unbounded[ChatMessage]
} yield ChatRoom(messages, hub)
def addMessage(message: ChatMessage): ZIO[ChatRoom, Nothing, Unit] =
ZIO.serviceWithZIO[ChatRoom] { room =>
for {
_ <- room.messages.update(_ :+ message)
_ <- room.subscribers.publish(message)
} yield ()
}
def getMessages: ZIO[ChatRoom, Nothing, List[ChatMessage]] =
ZIO.serviceWithZIO[ChatRoom](_.messages.get)
def subscribe: ZIO[ChatRoom & Scope, Nothing, UStream[ChatMessage]] =
ZIO.serviceWithZIO[ChatRoom] { room =>
room.subscribers.subscribe.map(ZStream.fromQueue(_))
}
val layer: ZLayer[Any, Nothing, ChatRoom] =
ZLayer.fromZIO(make)
}
Key patterns:
Ref[List[ChatMessage]]- Thread-safe mutable reference for message historyHub[ChatMessage]- Broadcasts messages to all subscriberssubscribe- Returns aZStreamthat receives new messages- ZLayer - Provides the
ChatRoomas a dependency
4. Server and Routes
The ChatServer ties everything together with HTTP routes and the HTML template:
package example.datastar.chat
import zio._
import zio.http._
import zio.http.datastar._
import zio.http.endpoint.Endpoint
import zio.http.template2._
import java.time.format.DateTimeFormatter
import java.time.{Instant, ZoneId}
object ChatServer extends ZIOAppDefault {
private val $username = Signal[String]("username")
private val $message = Signal[String]("message")
private val chatPage: Dom = html(
head(
meta(charset := "UTF-8"),
meta(name := "viewport", content := "width=device-width, initial-scale=1.0"),
title("ZIO Chat - Real-time Multi-Client Chat"),
datastarScript,
style.inlineResource("chat.css"),
),
body(
dataInit := Endpoint(Method.GET / "chat" / "messages").out[String].datastarRequest(()),
div(`class` := "header")(
h1("ZIO Chat"),
p(
"Real-time Multi-Client Chat with ZIO, ZIO HTTP & Datastar",
span(`class` := "connection-status")("CONNECTED"),
),
),
div(
`class` := "container",
dataSignals($username) := "",
dataSignals($message) := "",
)(
div(`class` := "username-section")(
label(`for` := "username")("Your Username"),
input(
`type` := "text",
id := "username",
placeholder := "Enter your username...",
dataBind("username"),
),
),
div(`class` := "chat-container")(
div(
`class` := "messages",
id := "messages",
)(
div(id := "message-list"),
),
div(`class` := "input-area")(
input(
`type` := "text",
id := "message",
placeholder := "Type your message...",
dataBind("message"),
required,
dataOn.keydown := js"evt.code === 'Enter' && @post('/chat/send')",
),
button(
`type` := "submit",
dataAttr("disabled") := js"(${$username} === '' || ${$message} === '')",
dataOn.click := js"@post('/chat/send')",
)("Send"),
),
),
),
script(js"""
// Auto-scroll to bottom when new messages arrive
const messagesContainer = document.getElementById('messages');
const observer = new MutationObserver(() => {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
});
observer.observe(messagesContainer, { childList: true, subtree: true });
"""),
),
)
private def messageTemplate(msg: ChatMessage): Dom = {
val time = Instant
.ofEpochMilli(msg.timestamp)
.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("HH:mm:ss"))
div(`class` := "message")(
div(`class` := "message-header")(
span(`class` := "message-username")(msg.username),
span(`class` := "message-time")(time),
),
div(`class` := "message-content")(msg.content),
)
}
private val routes = Routes(
Method.GET / "chat" -> handler {
Response.text(chatPage.render).addHeader("Content-Type", "text/html")
},
Method.GET / "chat" / "messages" -> events {
handler {
for {
messages <- ChatRoom.getMessages
_ <- ServerSentEventGenerator.patchElements(
messages.map(messageTemplate),
PatchElementOptions(
selector = Some(id("message-list")),
mode = ElementPatchMode.Inner,
),
)
messages <- ChatRoom.subscribe
_ <- messages.mapZIO { message =>
ServerSentEventGenerator.patchElements(
messageTemplate(message),
PatchElementOptions(
selector = Some(id("message-list")),
mode = ElementPatchMode.Append,
),
)
}.runDrain
} yield ()
}
},
Method.POST / "chat" / "send" ->
handler { (req: Request) =>
for {
rq <- req.readSignals[MessageRequest]
msg = ChatMessage(username = rq.username, content = rq.message)
_ <- ChatRoom.addMessage(msg)
} yield Response.ok
},
).sandbox @@ ErrorResponseConfig.debug @@ Middleware.debug
override def run: ZIO[Any, Throwable, Unit] =
Server
.serve(routes)
.provide(
Server.default,
ChatRoom.layer,
)
}
Let's break down the key parts:
Signal Declarations
private val $username = Signal[String]("username")
private val $message = Signal[String]("message")
These typed signal declarations are used in the HTML template for two-way data binding.
HTML Template with Datastar
The template uses several Datastar attributes:
datastarScript- Includes the Datastar JavaScript librarydataInit- Triggers initial data load via SSE when the page loadsdataSignals($username) := ""- Declares reactive signals with initial valuesdataBind("username")- Two-way binds input value to signaldataOn.keydown := js"..."- Handles keyboard eventsdataOn.click := js"@post('/chat/send')"- Sends message on button click
SSE Streaming Route
Method.GET / "chat" / "messages" -> events {
handler {
for
messages <- ChatRoom.getMessages
_ <- ServerSentEventGenerator.patchElements(
messages.map(messageTemplate),
PatchElementOptions(
selector = Some(id("message-list")),
mode = ElementPatchMode.Inner,
),
)
messages <- ChatRoom.subscribe
_ <- messages.mapZIO { message =>
ServerSentEventGenerator.patchElements(
messageTemplate(message),
PatchElementOptions(
selector = Some(id("message-list")),
mode = ElementPatchMode.Append,
),
)
}.runDrain
yield ()
}
}
This route:
- Sends existing messages immediately (with
Innermode to replace content) - Subscribes to the Hub for new messages
- Streams each new message as an SSE event (with
Appendmode)
Message Sending Route
Method.POST / "chat" / "send" ->
handler { (req: Request) =>
for
rq <- req.readSignals[MessageRequest]
msg = ChatMessage(username = rq.username, content = rq.message)
_ <- ChatRoom.addMessage(msg)
yield Response.ok
}
The readSignals[T] method extracts Datastar signals from the request body into a typed case class.
Running the Example
Clone the ZIO HTTP repository and run the example:
git clone https://github.com/zio/zio-http.git
cd zio-http
sbt "zioHttpExampleDatastarChat/run"
Open your browser to http://localhost:8080/chat.
To test multi-client functionality, open multiple browser tabs or windows.
How It Works
- Page Load: Browser requests
/chat, receives HTML with embedded Datastar - Initial Connection:
dataInittriggers GET/chat/messages, establishing SSE connection - Existing Messages: Server sends all existing messages via
patchElementswithInnermode - Subscription: Server subscribes to Hub and keeps connection open
- User Types: Input changes update
$usernameand$messagesignals locally - User Sends: Button click or Enter key triggers POST
/chat/sendwith signals - Broadcast: Server adds message to ChatRoom, Hub broadcasts to all subscribers
- Real-time Update: Each subscriber's SSE connection receives new message, DOM updates
Styling
The application uses an external CSS file loaded via style.inlineResource("chat.css"). This demonstrates how to load static resources in ZIO HTTP applications.
Next Steps
This example can be extended with:
- User authentication - Add login flow before chat access
- Multiple rooms - Support different chat channels
- Message persistence - Store messages in a database
- Typing indicators - Show when users are typing
- Read receipts - Track message delivery status
Related Documentation
- Datastar SDK Reference - Complete API documentation
- Server-Sent Events - SSE fundamentals
- HTML Templating - Template DSL reference