Skip to main content
LiveView automatically synchronizes state between the client and server, ensuring a smooth user experience even with network latency.

The Challenge

In any web application, client and server state can diverge temporarily due to network latency. Consider this scenario:

Example: Form Submission Race Condition

Step 1: User types "hello@example." and debounce triggers:
[ hello@example.    ]
    ------------
       SUBMIT
    ------------
Step 2: User finishes typing and clicks submit:
[ [email protected] ]
    ------------
     SUBMITTING
    ------------
Step 3: Server response from debounce arrives: Without proper handling, the client would roll back to:
[ hello@example.    ] ✓
    ------------
       SUBMIT
    ------------
This rollback would be disastrous—the user’s completed input and button state would be lost!

LiveView’s Solution

LiveView automatically handles this synchronization:
  1. Client is source of truth for current input values
  2. Tracks in-flight events and only applies changes once all events resolve
  3. Preserves focus state across server updates
For most cases, LiveView handles client-server synchronization automatically without any code changes needed.
See Form Bindings - JavaScript Client Specifics for complete details.

Optimistic UI with Loading Classes

LiveView automatically adds CSS loading classes to elements pushing events to the server.

Automatic Loading Classes

<button phx-click="clicked" phx-window-keydown="key">Click me</button>
  • On click: receives phx-click-loading class
  • On keydown: receives phx-keydown-loading class
  • Classes removed when server acknowledges the event

Available Loading Classes

EventLoading Class
phx-clickphx-click-loading
phx-changephx-change-loading
phx-submitphx-submit-loading
phx-focusphx-focus-loading
phx-blurphx-blur-loading
phx-window-keydownphx-keydown-loading
phx-window-keyupphx-keyup-loading

CSS Loading States

Create immediate visual feedback with CSS:
.phx-click-loading.opaque-on-click {
  opacity: 50%;
}
<button phx-click="save" class="opaque-on-click">Save</button>

Form-Specific Behavior

For events inside forms, loading classes apply to both the element and the form:
<form id="user-form" phx-change="validate" phx-submit="save">
  <input name="email" />
  <button type="submit">Save</button>
</form>
  • Input change: phx-change-loading on both input and form
  • Form submit: phx-submit-loading on both button and form

Disabled State

Customize button text during loading:
<button phx-disable-with="Submitting..." type="submit">Submit</button>
Buttons are automatically disabled during server acknowledgement. Use phx-disable-with to customize the text.

Tailwind Integration

Add custom variants for cleaner loading states:
// tailwind.config.js
plugins: [
  plugin(({ addVariant }) => {
    addVariant("phx-click-loading", [
      ".phx-click-loading&", 
      ".phx-click-loading &"
    ])
    addVariant("phx-submit-loading", [
      ".phx-submit-loading&", 
      ".phx-submit-loading &"
    ])
    addVariant("phx-change-loading", [
      ".phx-change-loading&", 
      ".phx-change-loading &"
    ])
  })
]
Use in templates:
<button phx-click="clicked" class="phx-click-loading:opacity-50">
  Click me
</button>

<div class="phx-submit-loading:animate-pulse">
  <button type="submit">Save</button>
</div>

Optimistic UI with JS Commands

For more control, use JS commands to specify which elements get loading states:
<button phx-click={JS.push("delete", loading: "#post-row-13")}>
  Delete
</button>

Common Patterns

Hide element immediately on click:
<button phx-click={JS.push("delete") |> JS.hide(to: "#post-row-13")}>
  Delete
</button>
JS commands are DOM-patch aware—operations persist across server updates.

Custom Client Events

Dispatch custom DOM events for specialized interactions:
window.addEventListener("app:clipcopy", (event) => {
  if ("clipboard" in navigator) {
    if (event.target.tagName === "INPUT") {
      navigator.clipboard.writeText(event.target.value)
    } else {
      navigator.clipboard.writeText(event.target.textContent)
    }
  } else {
    alert(
      "Sorry, your browser does not support clipboard copy."
    )
  }
})

Advanced: JS Hooks

For complex cases, use hooks to control exactly when server updates apply:
Hooks.CustomInput = {
  mounted() {
    // Take control of element updates
  },
  
  beforeUpdate() {
    // Prepare for server update
  },
  
  updated() {
    // Selectively apply server changes
  }
}
See Client Hooks for detailed documentation.

Live Navigation

LiveView provides classes and events for navigation state:

Connection Classes

Applied to the LiveView’s parent container:
ClassDescription
phx-connectedView connected to server
phx-loadingView not connected to server
phx-errorError occurred (applied with phx-loading)

Page Loading Events

Triggered for navigation via <.link>, push_navigate, push_patch, and phx-submit:
import topbar from "topbar"

window.addEventListener("phx:page-loading-start", info => {
  topbar.show(500)
  console.log("Navigation kind:", info.detail.kind)
})

window.addEventListener("phx:page-loading-stop", info => {
  topbar.hide()
})
Triggered whenever the URL changes:
window.addEventListener("phx:navigate", (e) => {
  console.log("Navigated to:", e.detail.href)
  console.log("Is patch:", e.detail.patch)
  console.log("Is back/forward:", e.detail.pop)
})
For navigation-aware logic, prefer phx:navigate over hook updated() callbacks, as hooks may fire before window.location is updated.

Complete Example: Optimistic Delete

<div id={"post-#{@post.id}"} class="post-row">
  <h3>{@post.title}</h3>
  <button phx-click={
    JS.push("delete", value: %{id: @post.id})
    |> JS.hide(to: "#post-#{@post.id}", transition: "fade-out-scale")
  } class="btn-delete">
    Delete
  </button>
</div>

Best Practices

Start with CSS loading classes for simple feedback:
.btn.phx-click-loading {
  opacity: 0.6;
  cursor: wait;
}

See Also

Build docs developers (and LLMs) love