Live Demo
Try it live - see the reactive TodoMVC in action!
This tutorial demonstrates the hypermedia/immediate mode approach to web development: backend-first development where the server drives UI updates by streaming HTML fragments to the client. We’ll use Datastar for reactive frontend bindings.
Live Demo
Try it live - see the reactive TodoMVC in action!
Original Guide
Datastar’s TodoMVC example - the original implementation this is based on.
Source Code
What may not be intuitive about event sourcing - and by extension, Datastar’s hypermedia approach - is when you have an idea, you don’t start with the UI. You start with the event stream. I like to call this “stream driven development”. So, we’re making a todo app? Let’s start by adding a todo event to the stream and build from there:
.append todos --meta {action: "add", text: "Learn event sourcing"}
───────┬─────────────────────────────────────────────────────topic │ todosid │ 03elvd78yq84vp6botmro8jythash │meta │ ────────┬──────────────────────│ action │ add│ text │ Learn event sourcing│ ────────┴──────────────────────ttl │───────┴─────────────────────────────────────────────────────
Check what’s in the store:
.cat | where topic == "todos"
─#─┬─topic─┬────────────id─────────────┬─hash─┬──────────────meta───────────────0 │ todos │ 03elvd78yq84vp6botmro8jyt │ │ ────────┬──────────────────────│ │ │ │ action │ add│ │ │ │ text │ Learn event sourcing│ │ │ │ ────────┴─────────────────────────┴───────┴───────────────────────────┴──────┴─────────────────────────────────
Now let’s add a second todo and capture the returned frame:
let todo = .append todos --meta {action: "add", text: "Build something cool"}$todo
───────┬─────────────────────────────────────────────────────topic │ todosid │ 03elvd8txt25bf13qyggp0cdphash │meta │ ────────┬──────────────────────│ action │ add│ text │ Build something cool│ ────────┴──────────────────────ttl │───────┴─────────────────────────────────────────────────────
OK, now let’s mark that second todo as complete. With event streams, you express updates by appending more events. We’ll make use of metadata to point to the frame we are modifying:
.append todos --meta {action: "toggle", id: $todo.id}
───────┬─────────────────────────────────────────────────────topic │ todosid │ 03elvdlutpeeusqvf1xfdf7ighash │meta │ ────────┬───────────────────────────│ action │ toggle│ id │ 03elvd8txt25bf13qyggp0cdp│ ────────┴───────────────────────────ttl │───────┴─────────────────────────────────────────────────────
Check the stream:
.cat | where topic == "todos"
─#─┬─topic─┬────────────id─────────────┬─hash─┬──────────────meta───────────────0 │ todos │ 03elvd78yq84vp6botmro8jyt │ │ ────────┬──────────────────────│ │ │ │ action │ add│ │ │ │ text │ Learn event sourcing│ │ │ │ ────────┴──────────────────────1 │ todos │ 03elvd8txt25bf13qyggp0cdp │ │ ────────┬──────────────────────│ │ │ │ action │ add│ │ │ │ text │ Build something cool│ │ │ │ ────────┴──────────────────────2 │ todos │ 03elvdlutpeeusqvf1xfdf7ig │ │ ────────┬───────────────────────────│ │ │ │ action │ toggle│ │ │ │ id │ 03elvd8txt25bf13qyggp0cdp│ │ │ │ ────────┴──────────────────────────────┴───────┴───────────────────────────┴──────┴─────────────────────────────────
You now have three events: two “add” events and one “toggle” event.
Raw events are awkward for presenting a summary of the current state of things. We want to create a projection of the stream. We can use an ad-hoc generator to aggregate events into state:
.cat | where topic == "todos" | generate {|frame, todos = []| match $frame.meta.action { "add" => { $todos | append {id: $frame.id text: $frame.meta.text completed: false} | {out: $in next: $in} } "toggle" => { $todos | each {|todo| if $todo.id == $frame.meta.id { {id: $todo.id text: $todo.text completed: (not $todo.completed)} } else { $todo } } | {out: $in next: $in} } _ => {next: $todos} }} | last
─#─┬────────────id─────────────┬─────────text─────────┬─completed─0 │ 03elvd78yq84vp6botmro8jyt │ Learn event sourcing │ false1 │ 03elvd8txt25bf13qyggp0cdp │ Build something cool │ true───┴───────────────────────────┴──────────────────────┴───────────
The generate command processes each event to build current state:
todos = []
completed: false
{out: $in next: $in}
emits the updated list| last
gives us the current aggregated stateThis shows the current aggregated state: a list of todos with their completion status.
Let’s see the aggregated projection update in real-time. In a second terminal window, run the same generate command with live streaming:
.cat -f | where topic == "todos" | generate {|frame, todos = []| match $frame.meta.action { "add" => { $todos | append {id: $frame.id text: $frame.meta.text completed: false} | {out: $in next: $in} } "toggle" => { $todos | each {|todo| if $todo.id == $frame.meta.id { {id: $todo.id text: $todo.text completed: (not $todo.completed)} } else { $todo } } | {out: $in next: $in} } _ => {next: $todos} }} | each { print ($in | table -e) }
─#─┬────────────id─────────────┬─────────text─────────┬─completed─0 │ 03elvd78yq84vp6botmro8jyt │ Learn event sourcing │ false───┴───────────────────────────┴──────────────────────┴────────────#─┬────────────id─────────────┬─────────text─────────┬─completed─0 │ 03elvd78yq84vp6botmro8jyt │ Learn event sourcing │ false1 │ 03elvd8txt25bf13qyggp0cdp │ Build something cool │ false───┴───────────────────────────┴──────────────────────┴────────────#─┬────────────id─────────────┬─────────text─────────┬─completed─0 │ 03elvd78yq84vp6botmro8jyt │ Learn event sourcing │ false1 │ 03elvd8txt25bf13qyggp0cdp │ Build something cool │ true───┴───────────────────────────┴──────────────────────┴───────────
You see three outputs - one for each event processed so far. Now toggle that same todo back to incomplete:
.append todos --meta {action: "toggle", id: $todo.id}
In your watching terminal, you immediately see the new aggregated state:
─#─┬────────────id─────────────┬─────────text─────────┬─completed─0 │ 03elvd78yq84vp6botmro8jyt │ Learn event sourcing │ false1 │ 03elvd8txt25bf13qyggp0cdp │ Build something cool │ false───┴───────────────────────────┴──────────────────────┴───────────
Notice both todos now show as incomplete. The aggregated projection automatically updated in real-time when the new event arrived - this is how reactive systems work with event streams.
Now let’s visualize this projection as HTML fragments. First, save a Jinja2 template:
r#'<ul id="todos">{%- for todo in todos %}<li data-completed="{{ todo.completed }}"> <label> <input type="checkbox" {% if todo.completed %}checked{% endif %} disabled /> {{ todo.text }} </label></li>{% endfor -%}</ul>'# | save todo_template.html
minijinja-cli is a fantastic tool that converts JSON to HTML using Jinja2 templates at the command line. In our example:
todo_template.html
- the template file-f json
- expect JSON input format-
- read the data that will be fed to the template from stdin[{ id: "1" text: "Test todo" completed: false} { id: "2" text: "Done todo" completed: true}] | {todos: $in} | to json -r | minijinja-cli -f json todo_template.html -
<ul id="todos"><li data-completed="false"><label><input type="checkbox" disabled />Test todo</label></li><li data-completed="true"><label><input type="checkbox" checked disabled />Done todo</label></li></ul>
Now run the live stream with HTML rendering:
.cat -f | where topic == "todos" | generate {|frame, todos = []| match $frame.meta.action { "add" => { $todos | append {id: $frame.id text: $frame.meta.text completed: false} | {out: $in next: $in} } "toggle" => { $todos | each {|todo| if $todo.id == $frame.meta.id { {id: $todo.id text: $todo.text completed: (not $todo.completed)} } else { $todo } } | {out: $in next: $in} } _ => {next: $todos} }} | each { {todos: $in} | to json -r | minijinja-cli -f json todo_template.html - | print $in }
<ul><li data-completed="false"><label><input type="checkbox" />Learn event sourcing</label></li></ul><ul><li data-completed="false"><label><input type="checkbox" />Learn event sourcing</label></li><li data-completed="false"><label><input type="checkbox" />Build something cool</label></li></ul>...
Now toggle the todo several more times and watch the HTML fragments update in real-time:
.append todos --meta {action: "toggle", id: $todo.id}.append todos --meta {action: "toggle", id: $todo.id}.append todos --meta {action: "toggle", id: $todo.id}
Each append immediately triggers a new HTML fragment in your watching terminal, showing the updated todo states.
Now let’s make our todo list available over HTTP. Save this closure to a file called serve.nu
:
# Point this to wherever you've installed the xs.nu moduleuse ~/.config/nushell/modules/xs.nu *
{|req| match $req { {method: "GET", path: "/"} => { .static "." "index.html" } {method: "GET", path: "/updates"} => { .response {headers: { "content-type": "text/event-stream", "cache-control": "no-cache", "access-control-allow-origin": "*" }}
.cat -f | where topic == "todos" | generate {|frame, todos = []| match $frame.meta.action { "add" => { $todos | append {id: $frame.id text: $frame.meta.text completed: false} | {out: $in next: $in} } "toggle" => { $todos | each {|todo| if $todo.id == $frame.meta.id { {id: $todo.id text: $todo.text completed: (not $todo.completed)} } else { $todo } } | {out: $in next: $in} } _ => {next: $todos} } } | each { let html = {todos: $in} | to json -r | minijinja-cli -f json todo_template.html - let sse_data = ($html | lines | each {|line| $"data: elements ($line)" } | str join "\n") $"event: datastar-patch-elements\n($sse_data)\n\n" } } _ => { .response {status: 404} "Not Found" } }}
Run the server:
cat serve.nu | http-nu :3001 -
Test the SSE endpoint with curl:
curl -sN http://localhost:3001/updates
You’ll see HTML fragments streaming in Datastar’s SSE format as events flow through the todos stream.
Create an index.html
that uses Datastar for reactive updates:
<!DOCTYPE html><html> <head> <title>TodoMVC with Datastar</title> <script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@main/bundles/datastar.js" ></script> </head> <body> <h1>TodoMVC with Datastar</h1>
<ul id="todos" data-on-load="@get('/updates')"> <li>Loading todos...</li> </ul> </body></html>
When the #todos
element loads, data-on-load
executes the @get
action to connect to our /updates
Server-Sent Events endpoint. Datastar receives the streamed HTML fragments and replaces the #todos
element whenever the todo state changes.
Visit http://localhost:3001 and then in your terminal try appending some more events:
.append todos --meta {action: "add", text: "Learn datastar"}let todo = .head todos.append todos --meta {action: "toggle", id: $todo.id}
Watch as each event immediately updates the web interface - this is hypermedia-driven reactive programming in action!
Let’s add an input field so users can add todos directly from the web interface. First, update the index.html
to include an input field:
<body> <h1>TodoMVC with Datastar</h1>
<input type="text" placeholder="What needs to be done?" data-signals-input data-bind-input data-on-keydown=" evt.key === 'Enter' && $input.trim() && @post('/add') && ($input = ''); " />
<ul id="todos" data-on-load="@get('/updates')"> <li>Loading todos...</li> </ul></body>
The input uses data-signals-input
to create a $input
signal, data-bind-input
to sync the field value with the signal, and data-on-keydown
to POST the todo text to our /add
endpoint when Enter is pressed, then clear the input field.
Now add the /add
endpoint to your serve.nu
by adding this case to the match block (you’ll need to restart http-nu after updating the file):
{method: "POST", path: "/add"} => { let text = $in | from json | get input | str trim .response {status: 204} .append todos --meta {action: "add" text: $text} | ignore}
This endpoint parses the JSON body to extract the input
field (Datastar sends {"input": "todo text"}
), appends a new todo event to the stream, and returns status 204.
Now the reactive loop is complete. When users type a todo and press Enter:
/add
endpoint with the todo text.append todos --meta {action: "add" text: $text}
creates a new event in the stream/updates
detects the new event via .cat -f | where topic == "todos"
#todos
elementAll connected browsers update simultaneously because they’re all listening to the same /updates
SSE stream.