Skip to content
djust/docs
Appearance
Mode
djust.org →
Browse documentation

5 min read

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 learnDocumented in
JS.show / hide / toggle / add_class / remove_classJS Commands
JS.transition for class-add → wait → class-remove cyclesJS Commands
Mixing JS chains with JS.push for server round-tripsJS Commands
When NOT to use JS Commands (state the server cares about)This tutorial

Prerequisites: Quickstart, the optimistic updates tutorial (recommended — JS Commands and @optimistic answer 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 careJS 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:

  1. JS.transition((from_class, to_class), time=N) swaps a "starting" class for an "ending" class on a target element, waits N ms (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.
  2. .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">&times;</button>
  </div>
{% endif %}

Two patterns to call out:

PatternEffect
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_profile event. The server re-renders with saved_message set; 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 transition pattern 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 actual delete event. 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 .invalid to 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.

Spotted a typo or want to improve this page? Edit on GitHub →