Tutorial: Modals and toasts without a server round-trip
There's a class of UI state the server has no business hearing about: a modal that's open, a dropdown that's expanded, a toast that's been shown and dismissed. Round-tripping every "open this panel" to the server costs ~80 ms of latency for a state change that's purely cosmetic — the user doesn't care whether the modal-open bit lives on the server or in the DOM.
djust's JS Commands are the right tool for this. They're the same kind of declarative chain Phoenix LiveView 1.0 introduced — a sequence of DOM operations that run client-side, in order, with no WebSocket frame. You author them in Python (so they're typechecked alongside your view), but they execute locally.
By the end of this tutorial you'll have:
- A "Profile" page with an Edit profile button that opens a modal instantly (no server round-trip).
- The modal has fade/scale CSS transitions triggered by adding
and removing classes — orchestrated by
JS.transition(), not by inline JS. - A save handler that posts the form server-side AND closes the modal AND shows a "Profile saved." toast — all in one declarative chain.
- A dismissable toast that auto-fades after 3 s — entirely client-side, no JS authored.
| You'll learn | Documented in |
|---|---|
JS.show / hide / toggle / add_class / remove_class | JS Commands |
JS.transition for class-add → wait → class-remove cycles | JS Commands |
Mixing JS chains with JS.push for server round-trips | JS Commands |
| When NOT to use JS Commands (state the server cares about) | This tutorial |
Prerequisites: Quickstart, the optimistic updates tutorial (recommended — JS Commands and
@optimisticanswer related but distinct questions). No JavaScript authoring required.
When JS Commands earn their keep
JS Commands cover client-only state. The decision rule:
| The state… | Use |
|---|---|
| Lives on the server (DB row, current count, etc.) | @event_handler (round-trip) |
| Will be authoritatively confirmed by the server | @optimistic |
| Affects only the immediate visual presentation, server doesn't care | JS Commands |
Examples of "server doesn't care": modal open/closed, dropdown expanded, accordion section visible, sidebar collapsed, toast shown, color-picker active, tab selected (when each tab's content is already in the DOM).
Examples where JS Commands are wrong: a "bookmark" toggle (server needs to persist), a "mark as read" button (server tracks read-state), a sort-column toggle (server may need to re-query). For those, you want a real handler.
The middle case — close the modal AND save the form — is solved
by combining: a JS chain that hides the modal locally and
calls JS.push("save") to fire the server event. One chain,
zero perceived latency on the close, full server confirmation on
the save.
Step 1 — The view + JS chains
# myapp/views.py
from djust import LiveView, action, state
from djust.js import JS
class ProfileView(LiveView):
template_name = "profile.html"
name = state("")
email = state("")
saved_message = state("")
def mount(self, request, **kwargs):
self.name = request.user.first_name or request.user.username
self.email = request.user.email
# Define the JS chains once. They're plain Python objects
# that the client interprets as a sequence of DOM ops.
self.open_modal = (
JS.add_class("modal-open", to="body")
.show("#edit-modal", display="flex")
.transition(("opacity-0", "opacity-100"), to="#edit-modal", time=200)
.focus("#name-input")
)
self.close_modal = (
JS.transition(("opacity-100", "opacity-0"), to="#edit-modal", time=200)
.hide("#edit-modal", time=200)
.remove_class("modal-open", to="body")
)
# Chain that fires both: hide the modal locally + send the
# save event to the server. The server's @action handler
# then sets `saved_message`, which triggers a re-render
# that shows the toast (and the toast auto-hides via its
# own JS chain — see Step 3).
self.save_and_close = self.close_modal.push("save_profile")
self.dismiss_toast = (
JS.transition(("opacity-100", "opacity-0"), to="#toast", time=200)
.hide("#toast", time=200)
)
@action
def save_profile(self, name: str = "", email: str = "", **kwargs):
self.name = name.strip()
self.email = email.strip()
# Persist to DB, etc. (omitted)
self.saved_message = "Profile saved."
Three chains, two important patterns:
JS.transition((from_class, to_class), time=N)swaps a "starting" class for an "ending" class on a target element, waitsNms (so CSS transitions can complete), then removes the ending class. This is how you fade-and-scale modals without writing a single line of JS..push("save_profile")at the end of the chain is the "escape hatch" — it's the one operation that crosses the network. Everything before it runs locally; everything after it (i.e. the re-render after the server handler) comes from the server.
Step 2 — The template
<!-- myapp/templates/profile.html -->
<section class="profile">
<h1>Your profile</h1>
<dl>
<dt>Name</dt> <dd>{{ name }}</dd>
<dt>Email</dt> <dd>{{ email }}</dd>
</dl>
<button type="button" dj-click="{{ open_modal }}">Edit profile</button>
</section>
<!-- The modal lives in the DOM at all times, hidden by default. -->
<div id="edit-modal" class="modal opacity-0" style="display: none;">
<form class="modal-card" dj-submit="{{ save_and_close }}">
<h2>Edit profile</h2>
<label>
Name
<input id="name-input" name="name" value="{{ name }}" required />
</label>
<label>
Email
<input name="email" type="email" value="{{ email }}" required />
</label>
<div class="modal-actions">
<button type="button" dj-click="{{ close_modal }}">Cancel</button>
<button type="submit">Save</button>
</div>
</form>
</div>
<!-- Toast renders into existence when saved_message is set. -->
{% if saved_message %}
<div id="toast" class="toast opacity-100"
dj-mounted-js="{{ dismiss_toast.delay(3000) }}">
{{ saved_message }}
<button type="button" dj-click="{{ dismiss_toast }}" aria-label="Dismiss">×</button>
</div>
{% endif %}
Two patterns to call out:
| Pattern | Effect |
|---|---|
dj-click="{{ open_modal }}" | Run the JS chain stored in self.open_modal when clicked. The chain is serialized to a JSON-ish form the client interprets. |
dj-mounted-js="{{ dismiss_toast.delay(3000) }}" | Run the chain 3 seconds after this element mounts — auto-dismiss the toast without authoring setTimeout. |
Step 3 — The CSS
.modal {
position: fixed;
inset: 0;
background: rgb(0 0 0 / 0.5);
display: flex; /* set by JS.show(display="flex") */
align-items: center;
justify-content: center;
z-index: 50;
transition: opacity 200ms ease-out;
}
.modal-card {
background: var(--color-bg, #fff);
border-radius: 8px;
padding: 24px;
width: min(420px, 100% - 32px);
}
.modal-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 16px;
}
.toast {
position: fixed;
bottom: 24px;
right: 24px;
padding: 12px 18px;
background: var(--color-accent, #10b981);
color: white;
border-radius: 6px;
box-shadow: 0 4px 12px rgb(0 0 0 / 0.15);
z-index: 60;
transition: opacity 200ms ease-out;
}
.opacity-0 { opacity: 0; }
.opacity-100 { opacity: 1; }
body.modal-open { overflow: hidden; }
The class names matter — JS.transition(("opacity-0", "opacity-100"))
adds opacity-0 first (matching the initial state), then on the
next frame swaps to opacity-100 so the CSS transition fires.
The 200 ms timer matches transition-duration: 200ms. If those
get out of sync, the modal either flickers (timer too short) or
holds the in-flight class too long (timer too long).
Step 4 — Wire the URL and try it
# myapp/urls.py
from django.urls import path
from .views import ProfileView
urlpatterns = [path("profile/", ProfileView.as_view())]
Visit /profile/:
- Click Edit profile → modal fades in instantly. Open the Network tab — no WebSocket frame fires.
- Type changes in the form → no events. The form is plain HTML until you submit.
- Click Cancel → modal fades out instantly. Still no network.
- Click Save → modal fades out (instant) + a single
WebSocket frame fires the
save_profileevent. The server re-renders withsaved_messageset; the toast appears, auto-hides 3 seconds later.
The "feel" is dramatically faster than a same-feature page where every interaction round-trips. The server still has authority over the actual save — but the UX overhead is local.
What just happened, end to end
Browser Server
│ │
│ click "Edit profile" │
│ ── JS chain runs locally: │
│ body.classList.add("modal-open") │
│ #edit-modal.style.display="flex" │
│ transition opacity-0 → 100 (200ms) │
│ focus #name-input │
│ (no WS frame) │
│ │
│ user types name + email │
│ (no events) │
│ │
│ click Save → chain runs: │
│ transition opacity-100 → 0 (200ms) │
│ hide #edit-modal (200ms in) │
│ remove modal-open from body │
│ push "save_profile" with form data │
│ ─────────────────────────────────────► save_profile()
│ │ self.name = ...
│ │ self.email = ...
│ │ self.saved_message = "Profile saved."
│ ◄ patch: <div id="toast">…</div> ─────│
│ toast renders; mounted JS chain │
│ auto-fires dismiss_toast.delay(3000) │
│ → fade out + hide after 3 s │
Two distinct round-trips eliminated (modal-open and modal-close) plus one preserved (the actual save). That's the JS Commands sweet spot.
Where to go next
- Animated dropdowns: the same
transitionpattern works for<details>panels, accordion sections, kebab menus. - Confirm-before-action: chain
JS.show("#confirm-dialog")on a Delete button, then have the dialog's primary action push the actualdeleteevent. No round-trip until the user confirms. - Tabs whose content is already in the DOM: ship all tabs
in the initial render, then switch via
JS.hide(".tab-panel").show("#tab-N")— instant, no re-render. - Sticky form errors: when a server validation fails, return
a JS chain from the handler that adds
.invalidto the bad fields.JS.dispatch("CustomEvent")to integrate with any JS validation library you already have. - Don't reach for a state library. "I need a Redux/Zustand store for this modal-open state" — usually you don't. JS Commands cover ~80% of what those libraries are bolted on for.
The decision tree (server-state → @event_handler,
server-confirms → @optimistic, client-only → JS Commands)
is the entire shape of UI-state ownership in djust. Once it
clicks, "where does this state live?" stops being a hard question.