---
title: "Tutorial: Modals and toasts without a server round-trip"
slug: tutorial-js-commands
section: guides
order: 70
level: beginner
description: "Build a modal dialog and a toast notification system using JS Commands — chained DOM operations that run client-side, without ever pinging the server. Then pair with a server push for the actual save. The right tool for ephemeral UI state the server doesn't care about."
---

# 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](/guides/js-commands/) |
| `JS.transition` for class-add → wait → class-remove cycles | [JS Commands](/guides/js-commands/) |
| Mixing JS chains with `JS.push` for server round-trips | [JS Commands](/guides/js-commands/) |
| When NOT to use JS Commands (state the server cares about) | This tutorial |

> **Prerequisites:** [Quickstart](/getting-started/), the [optimistic
> updates tutorial](/guides/tutorial-optimistic-updates/) (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 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

```python
# 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

```html
<!-- 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-hook="AutoDismiss">
    {{ saved_message }}
    <button type="button" dj-click="{{ dismiss_toast }}" aria-label="Dismiss">&times;</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-hook="AutoDismiss"` | Runs a JS chain when the element mounts. There is no attribute that schedules a chain on mount, so the delay lives in the hook:<br>`window.djust.hooks.AutoDismiss = { mounted() { setTimeout(() => this.js().hide().exec(this.el), 3000); } };` |

---

## Step 3 — The CSS

```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

```python
# 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.
