Tutorial: Optimistic UI updates with JS Commands
Build a todo list where toggling a todo's done state flips the UI instantly — before the server has even seen the click. The server still authoritatively confirms, or rolls the guess back, on the next round-trip. Uses a JS Command chain with push() and explains the reconciliation model.
On this page
The cheapest way to make a UI feel fast is to lie a little. When the user clicks "Like", flip the heart immediately — don't wait the 80 ms it takes the WebSocket round-trip to confirm. If the server later disagrees (rate-limited, deleted, permission-denied), correct the UI then. Most of the time it won't disagree, so most of the time the lie holds and the click feels instant.
djust does this with a JS Command chain on
dj-click:
- The chain's DOM operations (here,
add_class/remove_class) run in the browser immediately, before anything goes over the WebSocket. - Its final
push(...)sends the event to the server, which runs the handler and re-renders. When the server's render agrees with the guess, nothing visibly changes; when it rejects the change, the handler sends a JS Command back that undoes the guess.
Not
@optimistic.djust.decorators.optimisticexists, but it is a marker only: no client code reads it, so decorating a handler with it changes nothing at runtime. Use a JS Command chain as shown here.
By the end of this tutorial you'll have a todo list where:
- Toggling a todo's done state flips the checkbox + line-through immediately. No spinner, no delay.
- The server still updates the DB, and its re-render confirms the flip.
- Locked todos can't be toggled. The click still flips instantly, and the server rolls it back with a flash message a round-trip later. The user sees a brief incorrect state and then the truth.
| You'll learn | Documented in |
|---|---|
JS Command chains on dj-click, and push() | JS Commands |
Rolling a guess back with push_commands() | JS Commands |
| When NOT to use optimistic updates | This tutorial |
| Reconciliation model (client guess → server authority) | This tutorial |
Prerequisites: Quickstart, the search-as-you-type tutorial (sets up the event-handler vocabulary). Familiarity with
@event_handlerhelps.
When optimistic updates earn their keep
An optimistic update is a perf trick, not a correctness one. Use it when:
- The action's result is determined by the event data alone — toggling, liking, voting, marking-as-read. The server can't say anything the client doesn't already know.
- The failure cases are rare (rate limits, auth checks, race conditions). The cost of a brief wrong-then-right flicker is lower than the cost of every action feeling sluggish.
- The action is idempotent or self-healing — re-applying the server's authoritative diff has to land you in a consistent state regardless of the client's guess.
Don't use it when:
- The result depends on server-side logic the client can't predict (assigned-id, computed total, derived field).
- The failure case is common (e.g. a typeahead lookup that often returns no match — the user would see a flicker on every character).
- The user would be confused by the flicker more than helped by the speed (sensitive financial / medical confirmations).
Step 1 — The model
Standard Django:
# myapp/models.py
from django.conf import settings
from django.db import models
class Todo(models.Model):
title = models.CharField(max_length=200)
done = models.BooleanField(default=False)
locked = models.BooleanField(default=False)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
related_name="todos",
)
class Meta:
ordering = ["done", "-id"]
Step 2 — The view, with a JS Command per row
# myapp/views.py
from djust import LiveView, state
from djust.decorators import event_handler
from djust.js import JS
from .models import Todo
def _toggle_chain(todo):
# Flip the row's class in the browser now, then tell the server.
flip = JS.remove_class if todo.done else JS.add_class
return str(
flip("is-done", closest=".todo")
.push("toggle_todo", value={"todo_id": todo.id})
)
class TodoListView(LiveView):
template_name = "todos.html"
login_required = True
todos = state(default_factory=list)
def mount(self, request, **kwargs):
self._refresh()
def _refresh(self):
self.todos = [
{"id": t.id, "title": t.title, "done": t.done,
"toggle": _toggle_chain(t)}
for t in Todo.objects.filter(user=self.request.user)
]
@event_handler
def toggle_todo(self, todo_id: int = 0, **kwargs):
# Scoped to the user: another user's todo id is "not found",
# the same as an id that doesn't exist.
todo = Todo.objects.filter(id=todo_id, user=self.request.user).first()
if todo is None:
# The browser flipped a row the server won't change. Put
# every row back to what the server knows.
self.push_commands(self._resync())
self.put_flash("error", "That todo no longer exists.")
self._refresh()
return
if todo.locked:
# The browser already flipped the row. Nothing on the
# server changed, so the re-render won't touch it: undo
# the guess explicitly.
undo = JS.add_class if todo.done else JS.remove_class
self.push_commands(undo("is-done", to=f"#todo-{todo.id}"))
self.put_flash("error", "That todo is locked.")
return
todo.done = not todo.done
todo.save()
self._refresh()
def _resync(self):
# One command chain that sets every row's class to the
# server's value, whatever the browser guessed.
chain = JS.remove_class("is-done", to=".todo")
for t in self.todos:
if t["done"]:
chain = chain.add_class("is-done", to=f"#todo-{t['id']}")
return chain
Three pieces:
_toggle_chain()builds each row's click behaviour from the server's current state: addis-doneif the todo is open, remove it if it's done, thenpush("toggle_todo", ...). The chain is stored as its JSON string, so it is plain view state.- The happy path needs no reconciliation code. The handler
saves and re-renders; the server's new
classfor that row matches what the browser already shows, and the row's next chain points the other way. - The failure paths must undo the guess themselves. A class
that a JS Command set stays set until something changes it. When
the server's state didn't change, the re-render sends no patch for
that row, so the handler sends the inverse operation with
push_commands(). A locked todo gets its one row flipped back. An id that isn't one of the user's todos (deleted in another tab, or a forged event) is looked up withfilter(...).first()rather thanget(), so it can't raise, and_resync()resets every row to the server's state.
Step 3 — The template
<!-- myapp/templates/todos.html -->
<div dj-root>
{% load djust_flash %}
{% dj_flash %}
<ul class="todos">
{% for todo in todos %}
<li id="todo-{{ todo.id }}" dj-key="{{ todo.id }}"
class="todo {% if todo.done %}is-done{% endif %}">
<button
type="button"
dj-click="{{ todo.toggle }}"
aria-pressed="{{ todo.done|yesno:'true,false' }}"
class="todo-toggle"
>
<span class="todo-check" aria-hidden="true"></span>
<span class="todo-title">{{ todo.title }}</span>
</button>
</li>
{% endfor %}
</ul>
</div>
The pieces that make the optimistic flip work:
| Piece | Role |
|---|---|
dj-root on the outer <div> | Marks the reactive region. djust stamps dj-view onto it when it renders the page, and the client connects to that element. Without it the page renders but never connects, and no click reaches the server. |
dj-click="{{ todo.toggle }}" | dj-click recognises a JSON command list and runs it in the browser instead of sending a plain event. The chain ends in push, so the server still hears about the click. |
class="todo {% if todo.done %}is-done{% endif %}" | The CSS state hook. The chain flips it with closest=".todo"; the server's render sets it authoritatively. |
id="todo-{{ todo.id }}" | The target for the rollback command the handler sends. |
dj-key="{{ todo.id }}" | A stable key, so the diff matches rows by todo rather than by position when the list reorders (ordering = ["done", "-id"] moves a toggled todo). |
Step 4 — The CSS
.todo {
display: flex;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid var(--color-border, #e5e7eb);
transition: opacity 0.15s;
}
.todo.is-done {
opacity: 0.55;
}
.todo.is-done .todo-title {
text-decoration: line-through;
}
.todo-toggle {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
background: transparent;
border: 0;
padding: 0;
text-align: left;
cursor: pointer;
}
.todo-check {
display: inline-flex;
width: 20px;
height: 20px;
align-items: center;
justify-content: center;
border: 1.5px solid var(--color-muted, #9ca3af);
border-radius: 4px;
font-weight: 600;
}
.todo.is-done .todo-check {
background: var(--color-accent, #10b981);
border-color: var(--color-accent, #10b981);
color: white;
}
.todo.is-done .todo-check::after {
content: "✓";
}
The check mark comes from CSS on .is-done, so flipping the one
class updates everything the row shows. opacity and
text-decoration transitions are intentional — they
make the flip feel acknowledged without being abrupt. If you set
transition: none, the flip is sharper and the rare server-
correction is more visible.
Step 5 — Try it, including the failure path
# myapp/urls.py
from django.urls import path
from .views import TodoListView
urlpatterns = [path("todos/", TodoListView.as_view())]
Visit /todos/ and click a few todos:
- Each click flips its row instantly. The round-trip then lands a server render that matches what the browser predicted, so nothing visibly changes on confirmation.
- Now mark one todo
lockedin the admin and click it. It flips instantly too. ~60 ms later the handler's rollback command flips it back and the flash message appears. The user sees a brief wrong state then the truth.
That brief wrong state is the design — an optimistic update can't
suppress it without holding every update for at least one
round-trip, which would defeat the purpose. If you need the flicker
hidden, use a plain dj-click="toggle_todo" handler and accept the
~80 ms baseline latency.
What just happened, end to end
Browser Client runtime Server
│ │ │
│ click toggle (id=42) │ │
│ ──────────────────────────► │ │
│ │ run JS Command: │
│ │ add_class is-done │
│ ◄ DOM change (instant) ───── │ │
│ │ send event over WS │
│ │ ────────────────────► │
│ │ │ Todo.objects.get()
│ │ │ todo.done = True
│ │ │ todo.save()
│ │ │ diff template
│ │ ◄──── authoritative ──│
│ │ patch
│ │ row 42 already has │
│ │ is-done → no visible │
│ │ change │
│ (no further DOM change) │ │
When the todo is locked:
│ click toggle (locked todo) │ │
│ ──────────────────────────► │ │
│ ◄ DOM change (instant) ───── │ optimistic flip │
│ │ ────────────────────► │
│ │ │ todo.locked
│ │ ◄──── push_commands ──│ (no DB change)
│ │ (undo) + flash
│ ◄ DOM change (revert) ────── │ │
│ (brief wrong → corrected) │ │
Where to go next
- More JS Commands for non-state
optimistic UI (close a modal instantly, then save):
hide,show,transitionand the rest chain the same way. - Add a "Saving…" indicator for actions where the optimistic flip isn't fully sufficient (e.g. server-assigned id). Show a small inline spinner from the optimistic flip until the server diff lands; hide on confirmation.
- Rate limits —
@rate_limit(rate=..., burst=..., on_exceed="drop")drops excess events before the handler runs, so no handler code can roll the guess back. Keep rate-limited actions non-optimistic, or accept that a dropped click leaves its guess on screen until the row next re-renders. - Reconciliation logging — in dev, the latency simulator lets you add artificial WS delay to verify your optimistic UI handles real-world network conditions.
- Don't optimistic over auth boundaries — if the action requires
the user to be logged in and you're not 100% sure they still are,
leave it as a plain
dj-click="handler"so the auth-redirect doesn't flicker through a fake "success" state first.
The recipe — a JS Command that makes the guess, a push() that
tells the server, and a push_commands() rollback when the server
says no — is the same shape every "this should feel instant"
interaction uses: likes, votes, follows, mark-as-read, archive,
star, mute. Once you see the flicker happen once when the server
disagrees, the model clicks.