Skip to content

Instantly share code, notes, and snippets.

@CoreyCole
Last active May 3, 2026 01:33
Show Gist options
  • Select an option

  • Save CoreyCole/1101a465314bcc307281f3fc27f0a287 to your computer and use it in GitHub Desktop.

Select an option

Save CoreyCole/1101a465314bcc307281f3fc27f0a287 to your computer and use it in GitHub Desktop.
leptos+datastar

Below is the architecture I’d use for a multiplayer todo app with Leptos + Datastar + Axum SSE.

The important design choice: Leptos renders typed server-side HTML components. Datastar owns browser events and DOM patching. Axum owns routes, mutations, and SSE streams. Do not hydrate the same DOM subtree with Leptos WASM and then also morph it with Datastar.

Datastar’s model fits this well because it patches HTML elements from the backend using SSE and morphing, so the backend can stay the source of truth. (Datastar) The Rust SDK has Axum integration and PatchElements/PatchSignals primitives. (Docs.rs) Leptos supports SSR, and its Axum integration exists for server-side apps, but in this guide we are using Leptos mostly as a typed HTML renderer rather than a hydrated client framework. (Leptos Book)


Goal

Build a todo app where multiple browser tabs/users see the same todo list update in real time.

When Alice adds a todo, Bob sees it appear without refreshing. When Bob completes it, Alice sees it update.

The flow is:

Browser opens page
  -> Datastar starts /todos/stream SSE connection

User submits mutation
  -> POST /todos
  -> server mutates shared state
  -> server broadcasts "todos changed"

Every connected SSE stream receives event
  -> server re-renders TodoApp with Leptos SSR
  -> Datastar morphs #todo-app in every browser

Recommended boundaries

Use Leptos for:

typed components
component go-to-definition
typed props
server-side rendering to HTML strings

Use Datastar for:

data-on-click
data-on-submit
@post(...)
@get(...)
SSE patching
small client signals only when useful

Use Axum for:

HTTP routes
form parsing
shared app state
broadcast fanout
SSE responses

Avoid:

Leptos hydration + Datastar morphing the same component

That creates two owners of the DOM.


Project shape

multiplayer-todo/
  Cargo.toml
  src/
    main.rs
    model.rs
    views.rs
    routes.rs

You can start single-file, but a real app gets clearer if you split model, views, and routes.


Dependencies

[package]
name = "multiplayer-todo"
version = "0.1.0"
edition = "2021"

[dependencies]
async-stream = "0.3"
axum = { version = "0.8", features = ["form"] }
datastar = { version = "0.3", features = ["axum"] }
leptos = { version = "0.8", features = ["ssr"] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
uuid = { version = "1", features = ["v4", "serde"] }

For a production version, replace the in-memory store with Postgres.


Data model

Start with a simple in-memory store:

use std::collections::BTreeMap;
use uuid::Uuid;

#[derive(Clone, Debug)]
pub struct Todo {
    pub id: Uuid,
    pub title: String,
    pub done: bool,
    pub revision: u64,
}

#[derive(Default)]
pub struct TodoStore {
    pub todos: BTreeMap<Uuid, Todo>,
    pub revision: u64,
}

impl TodoStore {
    pub fn list(&self) -> Vec<Todo> {
        self.todos.values().cloned().collect()
    }

    pub fn create(&mut self, title: String) {
        self.revision += 1;

        let id = Uuid::new_v4();

        self.todos.insert(
            id,
            Todo {
                id,
                title,
                done: false,
                revision: self.revision,
            },
        );
    }

    pub fn toggle(&mut self, id: Uuid) {
        if let Some(todo) = self.todos.get_mut(&id) {
            self.revision += 1;
            todo.done = !todo.done;
            todo.revision = self.revision;
        }
    }

    pub fn delete(&mut self, id: Uuid) {
        if self.todos.remove(&id).is_some() {
            self.revision += 1;
        }
    }
}

For multiplayer, the key concept is the global revision. Every mutation increments it. Later, this lets you add conflict checks, optimistic UI, reconnect behavior, and “missed event” recovery.


Shared app state

use std::sync::{Arc, Mutex};
use tokio::sync::broadcast;

#[derive(Clone)]
pub struct AppState {
    pub store: Arc<Mutex<TodoStore>>,
    pub changed: broadcast::Sender<u64>,
}

impl AppState {
    pub fn snapshot(&self) -> (u64, Vec<Todo>) {
        let store = self.store.lock().unwrap();
        (store.revision, store.list())
    }

    pub fn notify_changed(&self) {
        let revision = {
            let store = self.store.lock().unwrap();
            store.revision
        };

        let _ = self.changed.send(revision);
    }
}

The broadcast::Sender is the multiplayer fanout mechanism. Each connected browser gets its own receiver.


Leptos views

The page shell can be a Leptos component too. That keeps everything Rust-native and navigable.

use leptos::prelude::*;

pub fn render_page(revision: u64, todos: Vec<Todo>) -> String {
    leptos::ssr::render_to_string(move || {
        view! {
            <!DOCTYPE html>
            <html lang="en">
                <head>
                    <meta charset="utf-8" />
                    <meta name="viewport" content="width=device-width, initial-scale=1" />
                    <title>"Multiplayer Todo"</title>
                    <script
                        type="module"
                        src="https://cdn.jsdelivr.net/gh/starfederation/datastar@main/bundles/datastar.js"
                    ></script>
                    <style>{APP_CSS}</style>
                </head>
                <body>
                    <TodoApp revision=revision todos=todos />
                </body>
            </html>
        }
    })
    .to_string()
}

pub fn render_todo_app(revision: u64, todos: Vec<Todo>) -> String {
    leptos::ssr::render_to_string(move || {
        view! {
            <TodoApp revision=revision todos=todos />
        }
    })
    .to_string()
}

Then the main component:

#[component]
pub fn TodoApp(revision: u64, todos: Vec<Todo>) -> impl IntoView {
    let remaining = todos.iter().filter(|todo| !todo.done).count();

    view! {
        <main
            id="todo-app"
            class="shell"
            data-revision=revision
            data-init="@get('/todos/stream')"
        >
            <header class="header">
                <div>
                    <h1>"Multiplayer Todo"</h1>
                    <p>"Everyone sees the same backend-owned list."</p>
                </div>

                <span class="revision">
                    "rev " {revision}
                </span>
            </header>

            <form
                class="new-todo"
                data-on-submit="
                    evt.preventDefault();
                    @post('/todos', {contentType: 'form'});
                    evt.target.reset();
                "
            >
                <input
                    name="title"
                    autocomplete="off"
                    placeholder="Add a todo..."
                />

                <button type="submit">
                    "Add"
                </button>
            </form>

            <TodoList todos=todos />

            <footer class="footer">
                {remaining} " remaining"
            </footer>
        </main>
    }
}

A todo list:

#[component]
pub fn TodoList(todos: Vec<Todo>) -> impl IntoView {
    if todos.is_empty() {
        return view! {
            <section id="todo-list" class="empty">
                "No todos yet."
            </section>
        }
        .into_any();
    }

    view! {
        <ul id="todo-list" class="todos">
            {todos
                .into_iter()
                .map(|todo| view! { <TodoRow todo=todo /> })
                .collect_view()}
        </ul>
    }
    .into_any()
}

A row:

#[component]
pub fn TodoRow(todo: Todo) -> impl IntoView {
    let class = if todo.done { "todo done" } else { "todo" };
    let toggle_label = if todo.done { "Undo" } else { "Done" };

    view! {
        <li id=format!("todo-{}", todo.id) class=class>
            <span class="title">
                {todo.title}
            </span>

            <div class="actions">
                <button
                    type="button"
                    data-on-click=format!("@post('/todos/{}/toggle')", todo.id)
                >
                    {toggle_label}
                </button>

                <button
                    type="button"
                    data-on-click=format!("@post('/todos/{}/delete')", todo.id)
                >
                    "Delete"
                </button>
            </div>
        </li>
    }
}

The key Datastar idea is that the row does not maintain private client state. The button posts to the backend. The backend mutates state. The shared SSE stream patches every client.


Routes

You want these routes:

GET  /
GET  /todos/stream

POST /todos
POST /todos/:id/toggle
POST /todos/:id/delete

GET / returns the initial HTML page.

GET /todos/stream is long-lived. It patches #todo-app whenever the store changes.

Mutation routes return 204 No Content. They do not need to return HTML because the stream will patch all clients.


Axum setup

use axum::{
    routing::{get, post},
    Router,
};
use std::sync::{Arc, Mutex};
use tokio::sync::broadcast;

#[tokio::main]
async fn main() {
    let (changed, _) = broadcast::channel(256);

    let state = AppState {
        store: Arc::new(Mutex::new(TodoStore::default())),
        changed,
    };

    let app = Router::new()
        .route("/", get(index))
        .route("/todos/stream", get(todo_stream))
        .route("/todos", post(create_todo))
        .route("/todos/{id}/toggle", post(toggle_todo))
        .route("/todos/{id}/delete", post(delete_todo))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .unwrap();

    axum::serve(listener, app).await.unwrap();
}

Index route

use axum::{extract::State, response::Html};

async fn index(State(state): State<AppState>) -> Html<String> {
    let (revision, todos) = state.snapshot();
    Html(render_page(revision, todos))
}

Create route

use axum::{extract::State, http::StatusCode, Form};
use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct CreateTodoForm {
    title: String,
}

async fn create_todo(
    State(state): State<AppState>,
    Form(form): Form<CreateTodoForm>,
) -> StatusCode {
    let title = form.title.trim();

    if title.is_empty() {
        return StatusCode::NO_CONTENT;
    }

    {
        let mut store = state.store.lock().unwrap();
        store.create(title.to_string());
    }

    state.notify_changed();

    StatusCode::NO_CONTENT
}

This is deliberately boring. The backend mutates state and broadcasts. The SSE connection handles rendering.


Toggle route

use axum::extract::Path;
use uuid::Uuid;

async fn toggle_todo(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> StatusCode {
    {
        let mut store = state.store.lock().unwrap();
        store.toggle(id);
    }

    state.notify_changed();

    StatusCode::NO_CONTENT
}

Delete route

async fn delete_todo(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> StatusCode {
    {
        let mut store = state.store.lock().unwrap();
        store.delete(id);
    }

    state.notify_changed();

    StatusCode::NO_CONTENT
}

SSE stream route

This is the most important part.

use async_stream::stream;
use axum::{extract::State, response::IntoResponse};
use datastar::prelude::*;

async fn todo_stream(State(state): State<AppState>) -> impl IntoResponse {
    let mut rx = state.changed.subscribe();

    datastar::axum::Sse(stream! {
        // Initial patch when the stream connects.
        let (revision, todos) = state.snapshot();
        yield PatchElements::new(render_todo_app(revision, todos)).into();

        loop {
            match rx.recv().await {
                Ok(_revision) => {
                    let (revision, todos) = state.snapshot();

                    // Fat morph the whole component.
                    yield PatchElements::new(render_todo_app(revision, todos)).into();
                }

                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
                    // Client missed one or more broadcasts.
                    // Re-send the full current component.
                    let (revision, todos) = state.snapshot();
                    yield PatchElements::new(render_todo_app(revision, todos)).into();
                }

                Err(tokio::sync::broadcast::error::RecvError::Closed) => {
                    break;
                }
            }
        }
    })
}

This is the “multiplayer” part. Every connected browser has a receiver. One mutation causes one broadcast. Every stream re-renders from the latest backend state.


Why fat morph the whole component?

For this app, patching the whole #todo-app is simpler and safer than sending targeted row patches.

Good starting rule:

One state change -> re-render the whole logical component -> Datastar morphs it

Datastar’s morphing behavior is specifically meant to update changed parts while preserving useful DOM state. (Datastar)

Later, you can optimize:

append one todo row
remove one todo row
patch only footer count
patch only one row

But for an admin-style SaaS app, fat morphs are usually the right first implementation.


Conflict model

For todos, last-write-wins is fine.

But multiplayer apps need an explicit stance. Start with:

create: always succeeds
toggle: last write wins
delete: idempotent
stream lag: resend full current state
reconnect: initial stream patch sends full current state

Then add revision checks only where needed.

For example, if you add inline editing:

POST /todos/:id/rename
fields:
  title
  expected_revision

If the todo’s current revision does not match expected_revision, return a patch that re-renders the row with an error:

"This todo changed in another tab. Review and try again."

For a simple toggle/delete app, do not overbuild this.


Persistence version

Once the in-memory version works, move the store to Postgres.

Suggested tables:

CREATE TABLE todo (
  id UUID PRIMARY KEY,
  title TEXT NOT NULL,
  done BOOLEAN NOT NULL DEFAULT false,
  revision BIGINT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE app_revision (
  id BOOLEAN PRIMARY KEY DEFAULT true,
  revision BIGINT NOT NULL
);

INSERT INTO app_revision (id, revision)
VALUES (true, 0);

Mutation transaction shape:

BEGIN
  increment app_revision
  insert/update/delete todo using new revision
COMMIT
broadcast new revision

In Rust, your mutation route becomes:

run SQL transaction
commit
broadcast revision
return 204

Do not broadcast before commit.


Multi-process production caveat

tokio::sync::broadcast only works inside one server process.

That is fine for local dev and one-instance deployment. For multiple server instances, use a cross-process pub/sub layer:

Postgres LISTEN/NOTIFY
Redis pub/sub
NATS
Kafka

For your use case, Postgres LISTEN/NOTIFY is probably the simplest first production step.

The mutation flow becomes:

HTTP mutation
  -> SQL transaction commits
  -> NOTIFY todos_changed, revision

Each app instance:
  -> LISTEN todos_changed
  -> local broadcast to connected SSE clients

Reconnect behavior

SSE can disconnect. Your app should tolerate this.

Datastar’s data-init="@get('/todos/stream')" reconnect behavior depends on the browser/runtime request lifecycle, but your server-side design should assume clients may reconnect at any time.

The stream endpoint should always do this first:

let (revision, todos) = state.snapshot();
yield PatchElements::new(render_todo_app(revision, todos)).into();

That makes reconnect safe.


UI state rules

Keep durable state on the backend:

todos
done flags
current filter if shared
sort order if shared
assignee
presence
revision

Keep ephemeral state in the browser:

input field value
open dropdown
hover state
temporary loading state

Use Datastar signals for ephemeral state only when they help. Do not mirror the full todo list into Datastar signals. In this architecture, the todo list is backend-owned HTML.


Presence feature

After basic multiplayer works, add presence.

Simplest version:

GET /presence/stream
POST /presence/heartbeat

But a cleaner Datastar/SSE version is to associate each stream connection with a client_id.

On stream connect:

mark client online
broadcast presence changed

On stream drop:

mark client offline
broadcast presence changed

Then render:

#[component]
fn Presence(users_online: usize) -> impl IntoView {
    view! {
        <span class="presence">
            {users_online} " online"
        </span>
    }
}

You can include presence in the same TodoApp fat morph or patch a smaller #presence element separately.


Full development sequence

Build it in this order:

1. Static Leptos SSR page
2. Render todos from in-memory state
3. Add POST /todos
4. Add GET /todos/stream
5. After create, broadcast and patch all clients
6. Add toggle/delete
7. Open two browser windows and verify both update
8. Add revision display
9. Add Postgres persistence
10. Replace in-process broadcast with Postgres LISTEN/NOTIFY if deploying multiple instances

Mental model

The app is not “Leptos app with Datastar sprinkled in.”

It is:

Axum app
  owns HTTP, mutations, and SSE

Leptos SSR
  owns typed HTML rendering

Datastar
  owns browser events and DOM patch application

That gives you the closest Rust version of your templ + Datastar taste:

typed server-rendered components
go-to-definition on components and structs
backend-owned state
HTML over the wire
minimal frontend build complexity
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment