Intermediate
Intermediate examples cover forms and validation, state management patterns, real-time communication with PubSub, and file upload handling. These examples assume understanding of LiveView basics (mount, assigns, events, templates) and demonstrate practical patterns for production applications.
Forms and Validation (Examples 31-40)
Forms in LiveView combine Ecto changesets with real-time validation, providing immediate feedback without page reloads.
Example 31: Form Basics with Changesets
Forms in LiveView use Ecto changesets for validation and transformation. The changeset tracks form state and validation errors in real-time.
Form validation architecture:
%% Ecto changeset form validation flow
graph TD
A[User Input] --> B[phx-change event]
B --> C[cast/3 Type conversion]
C --> D[validate_required]
D --> E[validate_format etc]
E --> F{Valid?}
F -->|Yes| G[changeset.valid? = true]
F -->|No| H[changeset.errors populated]
H --> I[Re-render with errors]
style A fill:#0173B2,color:#fff
style C fill:#DE8F05,color:#fff
style E fill:#029E73,color:#fff
style H fill:#CC78BC,color:#fff
style G fill:#CA9161,color:#fff
Code:
defmodule MyAppWeb.UserFormLive do
# => Defines module MyAppWeb.UserFormLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
alias MyApp.Accounts.User
# => Aliases MyApp.Accounts.User for shorter references
import Ecto.Changeset
# => Imports functions from Ecto.Changeset
# Initialize form with empty changeset
def mount(_params, _session, socket) do
# => Called on LiveView initialization
changeset = change_user(%User{}) # => Empty changeset for User struct
# => changeset.valid? is true (no validations run yet)
socket = assign(socket, :form, to_form(changeset)) # => Convert changeset to form struct
# => form ready for rendering with .to_form/1
{:ok, socket} # => Socket ready with form assign
# => Pattern: successful result — socket bound to returned value
end
# => Closes enclosing function/module/block definition
# Handle form validation on input changes
def handle_event("validate", %{"user" => user_params}, socket) do
# => Handles "validate" event from client
# Run validations without persisting
changeset =
# => Binds result to variable
%User{}
# => Creates empty User struct as base for changeset
|> change_user(user_params) # => Applies user_params to empty User
# => Pipes result into: change_user(user_params) # => Applies user_pa
|> Map.put(:action, :validate) # => Marks changeset as validation (shows errors)
# => changeset may have errors if validation fails
socket = assign(socket, :form, to_form(changeset)) # => Update form with validation results
# => socket.assigns.form = to_form(changeset)
{:noreply, socket} # => Re-render with error messages
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<.form for={@form} phx-change="validate" phx-submit="save">
<!-- => Form with live validation (phx-change) and submission (phx-submit) -->
<.input field={@form[:name]} label="Name" />
<!-- => Renders 'Name' text input with error display -->
<.input field={@form[:email]} label="Email" type="email" />
<!-- => Renders 'Email' email input with error display -->
<.button>Save</.button>
<!-- => Renders styled submit button -->
</.form>
<!-- => Closes .form element -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
defp change_user(user, attrs \\ %{}) do
# => Defines change_user function
# Define validation rules
user
# => user piped into following operations
|> cast(attrs, [:name, :email]) # => Allow name and email fields
# => Casts :name, :email from params — converts strings to typed values
|> validate_required([:name, :email]) # => Both fields required
# => Validates :name, :email are present — adds error if blank
|> validate_format(:email, ~r/@/) # => Email must contain @
# => Validates email matches regex pattern — error if no match
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definitionKey Takeaway: Use phx-change="validate" to trigger validation on every input change, providing real-time feedback with Ecto changesets.
Why It Matters: Real-time form validation is a core user experience feature in production applications. Using Ecto changesets for validation logic means your server and LiveView share the same validation rules, eliminating duplication and inconsistencies. Production applications - registration forms, checkout flows, complex admin interfaces - all benefit from immediate feedback. Users stay engaged when they see errors as they type rather than after submission. This pattern also prevents invalid data from reaching your database, centralizing validation at the Elixir layer.
Example 32: Form Validation - Live Error Display
LiveView automatically displays validation errors as users type, using the changeset's error tracking.
Code:
defmodule MyAppWeb.ProductFormLive do
# => Defines module MyAppWeb.ProductFormLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
import Ecto.Changeset
# => Imports functions from Ecto.Changeset
def mount(_params, _session, socket) do
# => Called on LiveView initialization
changeset = product_changeset(%{}) # => Empty changeset
# => changeset contains validation state and any errors
{:ok, assign(socket, form: to_form(changeset))} # => Form ready
# => Converts changeset to Phoenix.HTML.Form struct for rendering
end
# => Closes enclosing function/module/block definition
def handle_event("validate", %{"product" => params}, socket) do
# => Handles "validate" event from client
changeset =
# => Binds result to variable
params
# => params piped into following operations
|> product_changeset()
# => Creates changeset for validation
|> Map.put(:action, :validate) # => Show errors immediately
# => If price < 0, changeset.errors includes {:price, {"must be greater than 0", []}}
{:noreply, assign(socket, form: to_form(changeset))} # => Errors displayed in form
# => Converts changeset to Phoenix.HTML.Form struct for rendering
# => Re-render updates error display without page reload
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<.form for={@form} phx-change="validate">
<!-- => Form with live validation — phx-change fires on every input change -->
<.input field={@form[:name]} label="Product Name" />
<!-- => Renders 'Product Name' text input with error display -->
<%!-- Errors shown below input automatically --%>
<.input field={@form[:price]} label="Price" type="number" step="0.01" />
<!-- => Renders 'Price' number input with error display -->
<%!-- If price < 0, error "must be greater than 0" appears --%>
<.input field={@form[:quantity]} label="Quantity" type="number" />
<!-- => Renders 'Quantity' number input with error display -->
<%!-- If quantity not integer, error "must be an integer" appears --%>
</.form>
<!-- => Closes .form element -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
defp product_changeset(attrs) do
# => Defines product_changeset function
data = %{name: nil, price: nil, quantity: nil} # => Empty data
# => data = %{name: nil, price: nil, quantity: nil}
types = %{name: :string, price: :decimal, quantity: :integer} # => Field types
# => types = %{name: :string, price: :decimal, quanti
{data, types}
# => Creates schemaless changeset: {data, types} tuple for Ecto validation
|> cast(attrs, [:name, :price, :quantity]) # => Cast with type validation
# => Casts :name, :price, :quantity from params — converts strings to typed values
|> validate_required([:name, :price, :quantity]) # => All required
# => Validates :name, :price, :quantity are present — adds error if blank
|> validate_number(:price, greater_than: 0) # => Price must be positive
# => Validates price numeric value meets conditions
|> validate_number(:quantity, greater_than_or_equal_to: 0) # => Quantity >= 0
# => Validates quantity numeric value meets conditions
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definitionKey Takeaway: Phoenix form components automatically display validation errors from the changeset when :action is set to :validate.
Why It Matters: Displaying validation errors as users type dramatically reduces form abandonment. In production applications, this pattern removes the frustration of submitting a form and receiving a list of errors - users know immediately when a field is invalid. The approach is efficient: only changed fields trigger re-validation, and LiveView sends only the error diff to the client. For complex multi-field forms in business applications, real-time error display is the difference between a professional product and a frustrating one.
Example 33: Multi-field Forms
Handle complex forms with multiple related fields and cross-field validation.
Code:
defmodule MyAppWeb.AddressFormLive do
# => Defines module MyAppWeb.AddressFormLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
import Ecto.Changeset
# => Imports functions from Ecto.Changeset
def mount(_params, _session, socket) do
# => Called on LiveView initialization
changeset = address_changeset(%{}) # => Empty address
# => changeset contains validation state and any errors
{:ok, assign(socket, form: to_form(changeset))} # => Ready to render
# => Converts changeset to Phoenix.HTML.Form struct for rendering
end
# => Closes enclosing function/module/block definition
def handle_event("validate", %{"address" => params}, socket) do
# => Handles "validate" event from client
changeset =
# => Binds result to variable
params
# => params piped into following operations
|> address_changeset()
# => Creates changeset for validation
|> Map.put(:action, :validate) # => Validate immediately
# => action: :validate enables error display in form components
{:noreply, assign(socket, form: to_form(changeset))} # => Display errors
# => Converts changeset to Phoenix.HTML.Form struct for rendering
end
# => Closes enclosing function/module/block definition
def handle_event("save", %{"address" => params}, socket) do
# => Handles "save" event from client
case address_changeset(params) do
# => Creates changeset for validation
%{valid?: true} = changeset ->
# => Pattern: changeset is valid — proceed with success path
# Apply changeset to get validated data
address = apply_changes(changeset) # => Extract validated address map
# => address: %{street: "...", city: "...", postal_code: "..."}
IO.inspect(address, label: "Saved Address") # => Output: Saved Address: %{...}
# => Output: Saved Address: <inspected value printed to console>
{:noreply, socket} # => Success
%{valid?: false} = changeset ->
# => Pattern: changeset invalid — show validation errors
# Invalid data, show errors
{:noreply, assign(socket, form: to_form(changeset))} # => Display validation errors
# => Converts changeset to Phoenix.HTML.Form struct for rendering
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<.form for={@form} phx-change="validate" phx-submit="save">
<!-- => Form with live validation (phx-change) and submission (phx-submit) -->
<.input field={@form[:street]} label="Street Address" />
<!-- => Renders 'Street Address' text input with error display -->
<.input field={@form[:city]} label="City" />
<!-- => Renders 'City' text input with error display -->
<.input field={@form[:state]} label="State" />
<!-- => Renders 'State' text input with error display -->
<.input field={@form[:postal_code]} label="Postal Code" />
<!-- => Renders 'Postal Code' text input with error display -->
<.input field={@form[:country]} label="Country" />
<!-- => Renders 'Country' text input with error display -->
<.button>Save Address</.button>
<!-- => Renders styled submit button -->
</.form>
<!-- => Closes .form element -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
defp address_changeset(attrs) do
# => Defines address_changeset function
data = %{street: nil, city: nil, state: nil, postal_code: nil, country: nil}
# => data bound to result of %{street: nil, city: nil, state: nil, postal_code:
types = %{street: :string, city: :string, state: :string, postal_code: :string, country: :string}
# => types bound to result of %{street: :string, city: :string, state: :string,
{data, types}
# => Creates schemaless changeset: {data, types} tuple for Ecto validation
|> cast(attrs, [:street, :city, :state, :postal_code, :country])
# => Casts :street, :city, :state, :postal_code, :country from params to changeset
|> validate_required([:street, :city, :postal_code, :country]) # => State optional
# => Validates :street, :city, :postal_code, :country are present — adds error if blank
|> validate_length(:postal_code, min: 5, max: 10) # => Postal code length
# => Validates postal_code string length is within constraints
|> validate_format(:postal_code, ~r/^[0-9A-Z\s-]+$/i) # => Alphanumeric + space/dash
# => Validates postal_code matches regex pattern — error if no match
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definitionKey Takeaway: Use apply_changes/1 to extract validated data from valid changesets for processing or persistence.
Why It Matters: Multi-field forms appear in virtually every production application - user registration, product creation, customer profiles. LiveView's approach using Ecto changesets provides consistent validation across all fields simultaneously. The changeset tracks which fields changed, allowing targeted error display without re-validating everything on each keystroke. This pattern scales to forms with dozens of fields while maintaining performance. Combining multiple validates_* functions on a single changeset keeps all validation logic co-located and testable independently from the UI.
Example 34: Nested Forms - Embedded Schemas
Handle nested data structures like addresses embedded in user forms using inputs_for.
Code:
defmodule MyAppWeb.UserWithAddressLive do
# => Defines module MyAppWeb.UserWithAddressLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
import Ecto.Changeset
# => Imports functions from Ecto.Changeset
def mount(_params, _session, socket) do
# => Called on LiveView initialization
changeset = user_changeset(%{}) # => User with empty address
# => changeset contains validation state and any errors
{:ok, assign(socket, form: to_form(changeset))} # => Ready
# => Converts changeset to Phoenix.HTML.Form struct for rendering
end
# => Closes enclosing function/module/block definition
def handle_event("validate", %{"user" => params}, socket) do
# => Handles "validate" event from client
changeset =
# => Binds result to variable
params
# => params piped into following operations
|> user_changeset()
# => Creates changeset for validation
|> Map.put(:action, :validate)
# => Marks changeset action, enables error display
{:noreply, assign(socket, form: to_form(changeset))}
# => Updates socket assigns
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<.form for={@form} phx-change="validate">
<!-- => Form with live validation — phx-change fires on every input change -->
<.input field={@form[:name]} label="Name" />
<!-- => Renders 'Name' text input with error display -->
<.input field={@form[:email]} label="Email" type="email" />
<!-- => Renders 'Email' email input with error display -->
<%!-- Nested address fields using inputs_for --%>
<.inputs_for :let={address_form} field={@form[:address]}>
<!-- => Renders 'field' text input with error display -->
<h3>Address</h3>
<!-- => H3 heading element -->
<.input field={address_form[:street]} label="Street" />
<!-- => Renders 'Street' text input with error display -->
<.input field={address_form[:city]} label="City" />
<!-- => Renders 'City' text input with error display -->
<.input field={address_form[:postal_code]} label="Postal Code" />
<!-- => Renders 'Postal Code' text input with error display -->
</.inputs_for>
<!-- => Closes .inputs_for element -->
</.form>
<!-- => Closes .form element -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
defp user_changeset(attrs) do
# => Defines user_changeset function
data = %{name: nil, email: nil, address: %{street: nil, city: nil, postal_code: nil}}
# => data bound to result of %{name: nil, email: nil, address: %{street: nil, c
types = %{name: :string, email: :string, address: :map}
# => types bound to result of %{name: :string, email: :string, address: :map}
{data, types}
# => Creates schemaless changeset: {data, types} tuple for Ecto validation
|> cast(attrs, [:name, :email])
# => Casts :name, :email from params to changeset
|> validate_required([:name, :email])
# => Validates listed fields are present
# => Recursively validates embedded address schema
|> cast_embed(:address, with: &address_changeset/2) # => Validate nested address
# => address validation errors appear under address fields
end
# => Closes enclosing function/module/block definition
defp address_changeset(address, attrs) do
# => Defines address_changeset function
types = %{street: :string, city: :string, postal_code: :string}
# => types bound to result of %{street: :string, city: :string, postal_code: :st
{address, types}
# => Creates schemaless changeset: {address, types} tuple for Ecto validation
|> cast(attrs, [:street, :city, :postal_code])
# => Casts :street, :city, :postal_code from params to changeset
|> validate_required([:street, :city, :postal_code]) # => All address fields required
# => Validates :street, :city, :postal_code are present — adds error if blank
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definitionKey Takeaway: Use cast_embed/3 for nested data and inputs_for in templates to render nested form fields with automatic validation.
Why It Matters: Nested forms with embedded schemas represent complex domain models - a product with variants, an order with line items, a user with multiple addresses. In production applications, this pattern eliminates the need for multiple forms and page transitions. Ecto's cast_embed/3 handles the relationship, and inputs_for generates properly named form fields that group nested data correctly. The changeset validation applies recursively, catching errors in both parent and nested records. This is essential for data-intensive business applications where entities have complex hierarchies.
Example 35: Form Recovery - phx-auto-recover
Preserve form state during LiveView reconnections using phx-auto-recover.
Code:
defmodule MyAppWeb.LongFormLive do
# => Defines module MyAppWeb.LongFormLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
def mount(_params, _session, socket) do
# => Called on LiveView initialization
changeset = article_changeset(%{}) # => Empty article
# => changeset contains validation state and any errors
{:ok, assign(socket, form: to_form(changeset))} # => Ready
# => Converts changeset to Phoenix.HTML.Form struct for rendering
end
# => Closes enclosing function/module/block definition
def handle_event("validate", %{"article" => params}, socket) do
# => Handles "validate" event from client
changeset =
# => Binds result to variable
params
# => params piped into following operations
|> article_changeset()
# => Creates changeset for validation
|> Map.put(:action, :validate)
# => Marks changeset action, enables error display
{:noreply, assign(socket, form: to_form(changeset))} # => Update form
# => Converts changeset to Phoenix.HTML.Form struct for rendering
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<%!-- phx-auto-recover="ignore" preserves form inputs during reconnection --%>
<.form for={@form} phx-change="validate" phx-auto-recover="ignore">
<!-- => Form with live validation — phx-change fires on every input change -->
<.input field={@form[:title]} label="Article Title" />
<!-- => Renders 'Article Title' text input with error display -->
<%!-- Long textarea benefits most from auto-recovery --%>
<.input field={@form[:content]} label="Content" type="textarea" rows="20" />
<!-- => Renders 'Content' textarea input with error display -->
<%!-- If LiveView disconnects/reconnects, content preserved in browser --%>
<.input field={@form[:tags]} label="Tags (comma-separated)" />
<!-- => Renders 'Tags (comma-separated)' text input with error display -->
<.button>Save Draft</.button>
<!-- => Renders styled submit button -->
</.form>
<!-- => Closes .form element -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
defp article_changeset(attrs) do
# => Defines article_changeset function
data = %{title: nil, content: nil, tags: nil}
# => data bound to result of %{title: nil, content: nil, tags: nil}
types = %{title: :string, content: :string, tags: :string}
# => types bound to result of %{title: :string, content: :string, tags: :string}
{data, types}
# => Creates schemaless changeset: {data, types} tuple for Ecto validation
|> cast(attrs, [:title, :content, :tags])
# => Casts :title, :content, :tags from params to changeset
|> validate_required([:title, :content])
# => Validates listed fields are present
|> validate_length(:title, min: 5, max: 200)
# => Validates field string length constraints
|> validate_length(:content, min: 50)
# => Validates field string length constraints
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definitionKey Takeaway: Add phx-auto-recover="ignore" to forms to preserve user input during LiveView disconnections, critical for long-form content.
Why It Matters: LiveView connections are WebSocket-based and can drop momentarily on mobile networks or sleep/wake cycles. Without auto-recovery, users lose form data they spent time entering - a major source of user frustration and support tickets in production. The phx-auto-recover attribute preserves form state during reconnection, transparently restoring user work. For long-form content like article drafts, configuration wizards, or multi-step forms, this protection is essential. Production applications with high mobile usage should always implement auto-recovery on forms with significant user input.
Example 36: Submit Without Page Reload
Handle form submission with server-side processing and client-side feedback without full page reloads.
Code:
defmodule MyAppWeb.ContactFormLive do
# => Defines module MyAppWeb.ContactFormLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
import Ecto.Changeset
# => Imports functions from Ecto.Changeset
def mount(_params, _session, socket) do
# => Called on LiveView initialization
changeset = contact_changeset(%{}) # => Empty contact form
# => changeset contains validation state and any errors
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> assign(:form, to_form(changeset))
# => Sets assigns.form
|> assign(:submitted, false) # => Track submission state
# => socket.assigns.submitted = false) # => Track submission state
{:ok, socket} # => Ready
# => Pattern: successful result — socket bound to returned value
end
# => Closes enclosing function/module/block definition
def handle_event("validate", %{"contact" => params}, socket) do
# => Handles "validate" event from client
changeset =
# => Binds result to variable
params
# => params piped into following operations
|> contact_changeset()
# => Creates changeset for validation
|> Map.put(:action, :validate)
# => Marks changeset action, enables error display
{:noreply, assign(socket, form: to_form(changeset))} # => Live validation
# => Converts changeset to Phoenix.HTML.Form struct for rendering
end
# => Closes enclosing function/module/block definition
def handle_event("submit", %{"contact" => params}, socket) do
# => Handles "submit" event from client
changeset = contact_changeset(params) # => Final validation
# => changeset contains validation state and any errors
case changeset do
# => Pattern matches on result value
%{valid?: true} ->
# => Pattern: changeset is valid — proceed with success path
# Extract and process data
contact = apply_changes(changeset) # => %{name: "...", email: "...", message: "..."}
# => Extracts validated data struct from valid changeset
IO.inspect(contact, label: "Contact Submission") # => Log submission
# => Output: Contact Submission: <inspected value printed to console>
# Simulate sending email
Process.sleep(500) # => Simulate network delay
# => Pauses execution for given milliseconds (for simulation)
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> assign(:submitted, true) # => Mark as submitted
# => socket.assigns.submitted = true) # => Mark as submitted
|> assign(:form, to_form(contact_changeset(%{}))) # => Reset form
# => Form cleared, submitted flag true
{:noreply, socket} # => Re-render with success message
%{valid?: false} ->
# => Pattern: changeset invalid — show validation errors
# Show validation errors
changeset = Map.put(changeset, :action, :validate)
# => Returns new map with key updated
{:noreply, assign(socket, form: to_form(changeset))} # => Display errors
# => Converts changeset to Phoenix.HTML.Form struct for rendering
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<div>
<!-- => Div container wrapping component content -->
<%= if @submitted do %>
<!-- => Renders inner content only when @submitted is truthy -->
<div class="alert alert-success">
<!-- => Div container with class="alert alert-success" -->
Thank you! Your message has been sent.
</div>
<!-- => Closes outer div container -->
<% end %>
<!-- => End of conditional/loop block -->
<.form for={@form} phx-change="validate" phx-submit="submit">
<!-- => Form with live validation (phx-change) and submission (phx-submit) -->
<.input field={@form[:name]} label="Name" />
<!-- => Renders 'Name' text input with error display -->
<.input field={@form[:email]} label="Email" type="email" />
<!-- => Renders 'Email' email input with error display -->
<.input field={@form[:message]} label="Message" type="textarea" rows="5" />
<!-- => Renders 'Message' textarea input with error display -->
<.button>Send Message</.button>
<!-- => Renders styled submit button -->
</.form>
<!-- => Closes .form element -->
</div>
<!-- => Closes outer div container -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
defp contact_changeset(attrs) do
# => Defines contact_changeset function
data = %{name: nil, email: nil, message: nil}
# => data bound to result of %{name: nil, email: nil, message: nil}
types = %{name: :string, email: :string, message: :string}
# => types bound to result of %{name: :string, email: :string, message: :string}
{data, types}
# => Creates schemaless changeset: {data, types} tuple for Ecto validation
|> cast(attrs, [:name, :email, :message])
# => Casts :name, :email, :message from params to changeset
|> validate_required([:name, :email, :message])
# => Validates listed fields are present
|> validate_format(:email, ~r/@/)
# => Validates field matches regex pattern
|> validate_length(:message, min: 10)
# => Validates field string length constraints
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definitionKey Takeaway: Handle phx-submit events to process forms server-side without page reloads, showing success messages by updating assigns.
Why It Matters: Traditional form submission causes full page reloads, breaking user flow and requiring server-side session management for error states. LiveView forms submit over WebSocket, maintaining application state throughout the interaction. In production, this means users can submit forms without losing scroll position, notifications, or modal states. The server processes the submission, updates assigns, and re-renders only the changed parts. This approach also enables progressive success states - showing partial results while processing continues in the background using async operations.
Example 37: Form Input Types - Text, Checkbox, Select
LiveView supports all standard HTML5 input types with automatic value binding.
Code:
defmodule MyAppWeb.PreferencesFormLive do
# => Defines module MyAppWeb.PreferencesFormLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
import Ecto.Changeset
# => Imports functions from Ecto.Changeset
def mount(_params, _session, socket) do
# => Called on LiveView initialization
changeset = preferences_changeset(%{}) # => Empty preferences
# => changeset contains validation state and any errors
{:ok, assign(socket, form: to_form(changeset))} # => Ready
# => Converts changeset to Phoenix.HTML.Form struct for rendering
end
# => Closes enclosing function/module/block definition
def handle_event("validate", %{"preferences" => params}, socket) do
# => Handles "validate" event from client
changeset =
# => Binds result to variable
params
# => params piped into following operations
|> preferences_changeset()
# => Creates changeset for validation
|> Map.put(:action, :validate)
# => Marks changeset action, enables error display
{:noreply, assign(socket, form: to_form(changeset))}
# => Updates socket with validated changeset
# => Re-render shows errors for invalid fields
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<.form for={@form} phx-change="validate">
<!-- => Form with live validation — phx-change fires on every input change -->
<%!-- Text input — casts to :string --%>
<.input field={@form[:username]} label="Username" />
<!-- => Renders 'Username' text input with error display -->
<%!-- Checkbox input - boolean value --%>
<.input field={@form[:newsletter]} label="Subscribe to newsletter" type="checkbox" />
<!-- => Renders 'Subscribe to newsletter' checkbox input with error display -->
<%!-- Checked = true, unchecked = false --%>
<%!-- Select input - dropdown --%>
<.input
<!-- => Renders 'field' text input with error display -->
field={@form[:theme]}
# => field bound to result of {@form[:theme]}
label="Theme"
# => label bound to result of "Theme"
type="select"
# => type bound to result of "select"
options={[{"Light", "light"}, {"Dark", "dark"}, {"Auto", "auto"}]}
# => options bound to result of {[{"Light", "light"}, {"Dark", "dark"}, {"Auto", "
/>
<!-- => Self-closing tag — no inner content -->
<%!-- Options: [{"Display", "value"}, ...] --%>
<%!-- Number input --%>
<.input field={@form[:items_per_page]} label="Items per page" type="number" />
<!-- => Renders 'Items per page' number input with error display -->
<%!-- Email input with HTML5 validation --%>
<.input field={@form[:email]} label="Email" type="email" />
<!-- => Renders 'Email' email input with error display -->
</.form>
<!-- => Closes .form element -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
defp preferences_changeset(attrs) do
# => Defines preferences_changeset function
data = %{username: nil, newsletter: false, theme: "auto", items_per_page: 10, email: nil}
# => data bound to result of %{username: nil, newsletter: false, theme: "auto",
types = %{username: :string, newsletter: :boolean, theme: :string, items_per_page: :integer, email: :string}
# => types bound to result of %{username: :string, newsletter: :boolean, theme:
{data, types}
# => Creates schemaless changeset: {data, types} tuple for Ecto validation
|> cast(attrs, [:username, :newsletter, :theme, :items_per_page, :email])
# => Casts :username, :newsletter, :theme, :items_per_page, :email from params to changeset
|> validate_required([:username, :email])
# => Validates listed fields are present
|> validate_inclusion(:theme, ["light", "dark", "auto"]) # => Theme must be valid
# => Validates theme is one of the allowed values
|> validate_number(:items_per_page, greater_than: 0, less_than_or_equal_to: 100)
# => Validates field numeric constraints
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definitionKey Takeaway: Phoenix form components handle all HTML5 input types automatically, casting values to appropriate Elixir types via changesets.
Why It Matters: HTML5 input types provide semantic validation, keyboard optimization on mobile, and native UI widgets. LiveView's Phoenix.HTML.Form components handle the type conversion automatically - date inputs become Date structs, number inputs become integers, checkboxes become booleans. In production forms, using semantic input types improves accessibility (screen readers understand input purpose), reduces JavaScript (browser handles type validation), and improves mobile UX (correct keyboard appears). Using these types through changesets ensures consistent server-side casting regardless of how browsers format values.
Example 38: Custom Form Components
Create reusable form components for complex input patterns.
Code:
defmodule MyAppWeb.CustomFormLive do
# => Defines module MyAppWeb.CustomFormLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
def mount(_params, _session, socket) do
# => Called on LiveView initialization
changeset = event_changeset(%{}) # => Empty event
# => changeset contains validation state and any errors
{:ok, assign(socket, form: to_form(changeset))} # => Ready
# => Converts changeset to Phoenix.HTML.Form struct for rendering
end
# => Closes enclosing function/module/block definition
def handle_event("validate", %{"event" => params}, socket) do
# => Handles "validate" event from client
changeset =
# => Binds result to variable
params
# => params piped into following operations
|> event_changeset()
# => Creates changeset for validation
|> Map.put(:action, :validate)
# => Marks changeset action, enables error display
{:noreply, assign(socket, form: to_form(changeset))}
# => Updates socket assigns
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<.form for={@form} phx-change="validate">
<!-- => Form with live validation — phx-change fires on every input change -->
<.input field={@form[:title]} label="Event Title" />
<!-- => Renders 'Event Title' text input with error display -->
<%!-- Custom date-time picker component --%>
<.date_time_input field={@form[:starts_at]} label="Start Date & Time" />
<!-- => element HTML element -->
<%!-- Custom duration selector --%>
<.duration_input field={@form[:duration_minutes]} label="Duration" />
<!-- => element HTML element -->
</.form>
<!-- => Closes .form element -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
# Custom date-time input component
def date_time_input(assigns) do
# => Defines date_time_input function
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<div class="form-group">
<!-- => Div container with class="form-group" -->
<label><%= @label %></label>
<!-- => label HTML element -->
<input
<!-- => text input field -->
type="datetime-local"
# => type bound to result of "datetime-local"
id={@field.id}
# => id bound to result of {@field.id}
name={@field.name}
# => name bound to result of {@field.name}
value={@field.value}
# => value bound to result of {@field.value}
class="form-control"
# => class bound to result of "form-control"
/>
<!-- => Self-closing tag — no inner content -->
<%!-- Display errors if present --%>
<.error :for={msg <- @field.errors}><%= msg %></.error>
<!-- => element HTML element -->
</div>
<!-- => Closes outer div container -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
# Custom duration selector (hours + minutes)
def duration_input(assigns) do
# => Defines duration_input function
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<div class="form-group">
<!-- => Div container with class="form-group" -->
<label><%= @label %></label>
<!-- => label HTML element -->
<select name={@field.name} id={@field.id} class="form-control">
<!-- => select HTML element -->
<option value="30">30 minutes</option>
<!-- => option HTML element -->
<option value="60" selected={@field.value == "60"}>1 hour</option>
<!-- => option HTML element -->
<option value="90">1.5 hours</option>
<!-- => option HTML element -->
<option value="120">2 hours</option>
<!-- => option HTML element -->
</select>
<.error :for={msg <- @field.errors}><%= msg %></.error>
<!-- => element HTML element -->
</div>
<!-- => Closes outer div container -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
defp event_changeset(attrs) do
# => Defines event_changeset function
data = %{title: nil, starts_at: nil, duration_minutes: 60}
# => data bound to result of %{title: nil, starts_at: nil, duration_minutes: 60
types = %{title: :string, starts_at: :naive_datetime, duration_minutes: :integer}
# => types bound to result of %{title: :string, starts_at: :naive_datetime, dura
{data, types}
# => Creates schemaless changeset: {data, types} tuple for Ecto validation
|> cast(attrs, [:title, :starts_at, :duration_minutes])
# => Casts :title, :starts_at, :duration_minutes from params to changeset
|> validate_required([:title, :starts_at])
# => Validates listed fields are present
|> validate_number(:duration_minutes, greater_than: 0)
# => Validates field numeric constraints
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definitionKey Takeaway: Create custom function components for complex inputs by accessing @field.id, @field.name, @field.value, and @field.errors.
Why It Matters: Custom form components emerge naturally as applications grow - you find yourself repeating the same input structure (label, input, error message) across dozens of forms. Extracting this into a function component eliminates duplication and ensures consistent styling and behavior. In production applications, custom components also centralize accessibility features (proper label association, ARIA attributes) so they appear consistently. When design changes are needed - updating error styling, adding required field indicators - you change one component rather than dozens of template locations.
Example 39: File Upload Basics
Enable file uploads with allow_upload configuration and upload validation.
Code:
defmodule MyAppWeb.AvatarUploadLive do
# => Defines module MyAppWeb.AvatarUploadLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
def mount(_params, _session, socket) do
# => Called on LiveView initialization
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> assign(:uploaded_files, []) # => Track uploaded files
# => socket.assigns.uploaded_files = []) # => Track uploaded files
|> allow_upload(:avatar, # => Configure avatar upload
# => Configures avatar upload: sets file type, size, count limits
accept: ~w(.jpg .jpeg .png), # => Allowed extensions
max_entries: 1, # => Single file only
max_file_size: 5_000_000 # => 5MB limit (bytes)
)
# => Upload configuration stored in socket
{:ok, socket} # => Ready for uploads
# => Pattern: successful result — socket bound to returned value
end
# => Closes enclosing function/module/block definition
def handle_event("validate", _params, socket) do
# => Handles "validate" event from client
# Validation happens automatically based on allow_upload config
# => LiveView checks file type and size constraints from allow_upload
{:noreply, socket} # => Errors shown if file invalid
end
# => Closes enclosing function/module/block definition
def handle_event("save", _params, socket) do
# => Handles "save" event from client
# Consume uploaded files
uploaded_files =
# => Binds result to variable
consume_uploaded_entries(socket, :avatar, fn %{path: path}, entry ->
# => Processes uploaded files, returns results list
# path: temporary file path on server
# entry: upload metadata (client_name, content_type, etc.)
dest = Path.join("priv/static/uploads", entry.client_name) # => Destination path
# => dest bound to result of Path.join(...)
File.cp!(path, dest) # => Copy to permanent location
{:ok, "/uploads/#{entry.client_name}"} # => Return public URL
# => Pattern: successful result — result bound to returned value
end)
# => uploaded_files: ["/uploads/avatar.jpg"] or []
socket = assign(socket, :uploaded_files, uploaded_files) # => Store uploaded paths
# => socket.assigns.uploaded_files = uploaded_files
{:noreply, socket} # => Display uploaded files
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<div>
<!-- => Div container wrapping component content -->
<form phx-change="validate" phx-submit="save">
<!-- => form HTML element -->
<%!-- File input with upload configuration --%>
<.live_file_input upload={@uploads.avatar} />
<!-- => element HTML element -->
<%!-- Automatically validates against allow_upload rules --%>
<%!-- Show validation errors --%>
<%= for entry <- @uploads.avatar.entries do %>
<!-- => Loops over @uploads.avatar.entries, binding each element to entry -->
<div>
<!-- => Div container wrapping component content -->
<%= entry.client_name %> - <%= entry.progress %>%
<!-- => Evaluates Elixir expression and outputs result as HTML -->
<%!-- Display upload errors --%>
<%= for err <- upload_errors(@uploads.avatar, entry) do %>
<!-- => Loops over upload_errors(@uploads.avatar,, binding each element to err -->
<p class="error"><%= error_to_string(err) %></p>
<!-- => Paragraph element displaying dynamic content -->
<% end %>
<!-- => End of conditional/loop block -->
</div>
<!-- => Closes outer div container -->
<% end %>
<!-- => End of conditional/loop block -->
<button type="submit">Upload</button>
<!-- => Submit button — triggers phx-submit form event -->
</form>
<!-- => Closes form element — phx-submit/phx-change handlers deactivated -->
<%!-- Display uploaded files --%>
<%= for file <- @uploaded_files do %>
<!-- => Loops over @uploaded_files, binding each element to file -->
<img src={file} alt="Uploaded avatar" width="200" />
<!-- => img HTML element -->
<% end %>
<!-- => End of conditional/loop block -->
</div>
<!-- => Closes outer div container -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
defp error_to_string(:too_large), do: "File too large (max 5MB)"
# => Defines error_to_string function
defp error_to_string(:not_accepted), do: "Invalid file type (jpg, jpeg, png only)"
# => Defines error_to_string function
end
# => Closes enclosing function/module/block definitionKey Takeaway: Use allow_upload/3 to configure uploads with validation rules, then consume_uploaded_entries/3 to process uploaded files.
Why It Matters: File upload is a common requirement in production applications: user avatars, document attachments, image galleries. LiveView's built-in file upload handles the complexity of multi-part form encoding, chunked uploading, progress tracking, and client-side validation without requiring custom JavaScript or third-party libraries. The allow_upload configuration declares constraints declaratively, and LiveView enforces them before processing begins. This prevents invalid files from consuming server resources or reaching storage services. Understanding this foundation is required before adding features like image previews, drag-and-drop, or cloud storage integration.
Example 40: Form Progress Tracking
Track multi-step form progress with client-side state and server validation.
Code:
defmodule MyAppWeb.WizardFormLive do
# => Defines module MyAppWeb.WizardFormLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
def mount(_params, _session, socket) do
# => Called on LiveView initialization
changeset = registration_changeset(%{}) # => Empty registration
# => changeset contains validation state and any errors
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> assign(:form, to_form(changeset))
# => Sets assigns.form
|> assign(:current_step, 1) # => Start at step 1
# => socket.assigns.current_step = 1) # => Start at step 1
|> assign(:max_step, 3) # => 3 steps total
# => socket.assigns.max_step = 3) # => 3 steps total
{:ok, socket} # => Ready
# => Pattern: successful result — socket bound to returned value
end
# => Closes enclosing function/module/block definition
def handle_event("validate", %{"registration" => params}, socket) do
# => Handles "validate" event from client
changeset =
# => Binds result to variable
params
# => params piped into following operations
|> registration_changeset()
# => Creates changeset for validation
|> Map.put(:action, :validate)
# => Marks changeset action, enables error display
{:noreply, assign(socket, form: to_form(changeset))}
# => Updates socket assigns
end
# => Closes enclosing function/module/block definition
def handle_event("next_step", _params, socket) do
# => Handles "next_step" event from client
# Move to next step if not at max
new_step = min(socket.assigns.current_step + 1, socket.assigns.max_step)
# => new_step bound to result of min(socket.assigns.current_step + 1, socket.assign
{:noreply, assign(socket, :current_step, new_step)} # => Increment step
end
# => Closes enclosing function/module/block definition
def handle_event("prev_step", _params, socket) do
# => Handles "prev_step" event from client
# Move to previous step if not at first
new_step = max(socket.assigns.current_step - 1, 1)
# => new_step bound to result of max(socket.assigns.current_step - 1, 1)
{:noreply, assign(socket, :current_step, new_step)} # => Decrement step
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<div>
<!-- => Div container wrapping component content -->
<%!-- Progress indicator --%>
<div class="progress-bar">
<!-- => Div container with class="progress-bar" -->
Step <%= @current_step %> of <%= @max_step %>
<!-- => Static text with interpolated Elixir expression -->
<%= trunc((@current_step / @max_step) * 100) %>% complete
<!-- => Evaluates Elixir expression and outputs result as HTML -->
</div>
<!-- => Closes outer div container -->
<.form for={@form} phx-change="validate">
<!-- => Form with live validation — phx-change fires on every input change -->
<%!-- Step 1: Personal Info --%>
<%= if @current_step == 1 do %>
<!-- => Renders inner content only when @current_step == 1 is truthy -->
<.input field={@form[:name]} label="Full Name" />
<!-- => Renders 'Full Name' text input with error display -->
<.input field={@form[:email]} label="Email" type="email" />
<!-- => Renders 'Email' email input with error display -->
<% end %>
<!-- => End of conditional/loop block -->
<%!-- Step 2: Address --%>
<%= if @current_step == 2 do %>
<!-- => Renders inner content only when @current_step == 2 is truthy -->
<.input field={@form[:street]} label="Street Address" />
<!-- => Renders 'Street Address' text input with error display -->
<.input field={@form[:city]} label="City" />
<!-- => Renders 'City' text input with error display -->
<% end %>
<!-- => End of conditional/loop block -->
<%!-- Step 3: Confirmation --%>
<%= if @current_step == 3 do %>
<!-- => Renders inner content only when @current_step == 3 is truthy -->
<p>Review your information:</p>
<!-- => Paragraph element displaying dynamic content -->
<p>Name: <%= @form[:name].value %></p>
<!-- => Paragraph element displaying dynamic content -->
<p>Email: <%= @form[:email].value %></p>
<!-- => Paragraph element displaying dynamic content -->
<p>Address: <%= @form[:street].value %>, <%= @form[:city].value %></p>
<!-- => Paragraph element displaying dynamic content -->
<% end %>
<!-- => End of conditional/loop block -->
<%!-- Navigation buttons --%>
<button type="button" phx-click="prev_step" disabled={@current_step == 1}>
<!-- => Button triggers handle_event("prev_step", ...) on click -->
Previous
</button>
<!-- => Closes button element -->
<button type="button" phx-click="next_step" disabled={@current_step == @max_step}>
<!-- => Button triggers handle_event("next_step", ...) on click -->
Next
</button>
<!-- => Closes button element -->
<%= if @current_step == @max_step do %>
<!-- => Renders inner content only when @current_step == @max_step is truthy -->
<button type="submit">Submit</button>
<!-- => Submit button — triggers phx-submit form event -->
<% end %>
<!-- => End of conditional/loop block -->
</.form>
<!-- => Closes .form element -->
</div>
<!-- => Closes outer div container -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
defp registration_changeset(attrs) do
# => Defines registration_changeset function
data = %{name: nil, email: nil, street: nil, city: nil}
# => data bound to result of %{name: nil, email: nil, street: nil, city: nil}
types = %{name: :string, email: :string, street: :string, city: :string}
# => types bound to result of %{name: :string, email: :string, street: :string,
{data, types}
# => Creates schemaless changeset: {data, types} tuple for Ecto validation
|> cast(attrs, [:name, :email, :street, :city])
# => Casts :name, :email, :street, :city from params to changeset
|> validate_required([:name, :email])
# => Validates listed fields are present
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definitionKey Takeaway: Track multi-step form progress with a step counter assign, conditionally rendering form sections based on current step.
Why It Matters: Multi-step forms represent complex workflows in production applications: checkout funnels, onboarding sequences, configuration wizards. Tracking progress server-side with socket assigns means the user's state is preserved if they navigate away and return (assuming session persistence). The step counter approach also enables validation at each step before allowing progression, preventing users from reaching later steps with invalid earlier data. In production, this pattern also enables analytics - you can track where users abandon multi-step flows and optimize accordingly.
State Management (Examples 41-50)
State management patterns optimize LiveView performance and handle complex data flows.
Example 41: Temporary Assigns
Use temporary assigns for large lists that don't need to persist in memory between updates.
Memory management with temporary assigns:
%% Temporary assigns memory lifecycle
graph LR
A[Large Data Loaded] --> B[Assign to socket]
B --> C[render/1 sends HTML]
C --> D[Temporary assign cleared]
D --> E[socket.assigns.logs = empty]
E --> F[New data arrives]
F --> B
style A fill:#0173B2,color:#fff
style B fill:#DE8F05,color:#fff
style C fill:#029E73,color:#fff
style D fill:#CC78BC,color:#fff
style E fill:#CA9161,color:#fff
Code:
defmodule MyAppWeb.LogViewerLive do
# => Defines module MyAppWeb.LogViewerLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
def mount(_params, _session, socket) do
# => Called on LiveView initialization
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> assign(:logs, fetch_logs()) # => Load initial logs
# => socket.assigns.logs = fetch_logs()) # => Load initial logs
|> assign(:page, 1) # => Current page number
# => socket.assigns.page = 1) # => Current page number
{:ok, socket, temporary_assigns: [logs: []]} # => logs cleared after render
# => After render, socket.assigns.logs becomes []
# => Reduces memory for large log lists
end
# => Closes enclosing function/module/block definition
def handle_event("load_more", _params, socket) do
# => Handles "load_more" event from client
page = socket.assigns.page + 1 # => Increment page
# => page = socket.assigns.page + 1 # => Increment p
new_logs = fetch_logs(page) # => Fetch next page
# => new_logs = fetch_logs(page) # => Fetch next page
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> assign(:logs, new_logs) # => Assign new logs (old logs already cleared)
# => socket.assigns.logs = new_logs) # => Assign new logs (old logs
|> assign(:page, page) # => Update page number
# => socket.assigns.page = page) # => Update page number
{:noreply, socket} # => Render new logs, then clear from memory
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<div>
<!-- => Div container wrapping component content -->
<h2>Application Logs</h2>
<!-- => H2 heading element -->
<ul>
<!-- => List container for rendered items -->
<%= for log <- @logs do %>
<!-- => Loops over @logs, binding each element to log -->
<li><%= log.timestamp %> - <%= log.message %></li>
<!-- => List item rendered for each element -->
<% end %>
<!-- => End of conditional/loop block -->
</ul>
<!-- => Closes unordered list container -->
<button phx-click="load_more">Load More</button>
<!-- => Button triggers handle_event("load_more", ...) on click -->
</div>
<!-- => Closes outer div container -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
defp fetch_logs(page \\ 1) do
# => Defines fetch_logs function
# Simulate fetching logs from database
Enum.map(1..50, fn i ->
# => Transforms each element in list
%{timestamp: DateTime.utc_now(), message: "Log entry #{(page - 1) * 50 + i}"}
end)
# => Closes anonymous function; returns result to calling function
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definitionKey Takeaway: Use temporary_assigns in mount's return tuple to automatically clear large data after rendering, reducing LiveView process memory.
Why It Matters: Large LiveView processes accumulate memory as lists grow - a live log viewer, activity feed, or search results page can exhaust memory if every item persists in socket assigns. Temporary assigns solve this by clearing large data structures after each render, keeping only what's needed for the next update. In production applications processing high-frequency data streams or displaying historical records, temporary assigns prevent out-of-memory crashes. This pattern is the foundation for streaming large datasets, as the client holds displayed data while the server holds only what's new.
Example 42: assign_new for Lazy Evaluation
Use assign_new/3 to lazily compute expensive assigns only when they don't exist.
Code:
defmodule MyAppWeb.DashboardLive do
# => Defines module MyAppWeb.DashboardLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
def mount(_params, _session, socket) do
# => Called on LiveView initialization
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> assign(:user_id, 123) # => Set user_id
# => socket.assigns.user_id = 123) # => Set user_id
|> assign_new(:stats, fn -> compute_expensive_stats(123) end) # => Lazy load stats
# => compute_expensive_stats only runs if :stats not already assigned
{:ok, socket} # => Ready
# => Pattern: successful result — socket bound to returned value
end
# => Closes enclosing function/module/block definition
def handle_event("refresh_stats", _params, socket) do
# => Handles "refresh_stats" event from client
# Force recalculation by removing and re-adding
stats = compute_expensive_stats(socket.assigns.user_id) # => Recompute
# => stats = compute_expensive_stats(socket.assigns.u
{:noreply, assign(socket, :stats, stats)} # => Update stats
end
# => Closes enclosing function/module/block definition
def handle_params(_params, _uri, socket) do
# => Defines handle_params function
# assign_new won't recompute stats on navigation
socket = assign_new(socket, :stats, fn -> compute_expensive_stats(socket.assigns.user_id) end)
# => Sets assign only if key missing
# => stats remain from previous load
{:noreply, socket} # => Keep existing stats
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<div>
<!-- => Div container wrapping component content -->
<h2>Dashboard</h2>
<!-- => H2 heading element -->
<p>Total Sales: <%= @stats.total_sales %></p>
<!-- => Paragraph element displaying dynamic content -->
<p>Active Users: <%= @stats.active_users %></p>
<!-- => Paragraph element displaying dynamic content -->
<button phx-click="refresh_stats">Refresh</button>
<!-- => Button triggers handle_event("refresh_stats", ...) on click -->
</div>
<!-- => Closes outer div container -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
defp compute_expensive_stats(user_id) do
# => Defines compute_expensive_stats function
IO.puts("Computing expensive stats for user #{user_id}...")
# => Output: prints string to console
Process.sleep(1000) # => Simulate expensive computation
# => Pauses execution for given milliseconds (for simulation)
%{total_sales: 10_000, active_users: 250} # => Stats data
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definitionKey Takeaway: Use assign_new/3 to lazily compute expensive assigns only when missing, preventing redundant calculations on navigation.
Why It Matters: Computing expensive data on every page navigation wastes resources when users navigate back to pages they've already loaded. assign_new checks if an assign already exists before executing the computation, making it ideal for data loaded from database queries or external APIs. In production LiveViews with navigation between multiple states, assign_new prevents redundant database queries on back navigation. The lazy initialization pattern also applies to expensive transformations - sorting, filtering, grouping - that should only run once when the underlying data hasn't changed.
Example 43: Update Patterns - update/3
Use update/3 to modify existing assigns based on their current value.
Code:
defmodule MyAppWeb.CounterListLive do
# => Defines module MyAppWeb.CounterListLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
def mount(_params, _session, socket) do
# => Called on LiveView initialization
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> assign(:counters, %{a: 0, b: 0, c: 0}) # => Three independent counters
# => socket.assigns.counters = %{a: 0, b: 0, c: 0}) # => Three independ
|> assign(:total_clicks, 0) # => Global click counter
# => socket.assigns.total_clicks = 0) # => Global click counter
{:ok, socket} # => Ready
# => Pattern: successful result — socket bound to returned value
end
# => Closes enclosing function/module/block definition
def handle_event("increment", %{"counter" => key}, socket) do
# => Handles "increment" event from client
counter_key = String.to_atom(key) # => Convert "a" to :a
# => Converts string to atom — use carefully, atoms not GC'd
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> update(:counters, fn counters ->
# => Updates assign using current value
# Update nested map value
Map.update!(counters, counter_key, &(&1 + 1)) # => Increment specific counter
# => If counter_key is :a, counters becomes %{a: 1, b: 0, c: 0}
end)
# => Closes anonymous function; returns result to calling function
|> update(:total_clicks, &(&1 + 1)) # => Increment total
# => total_clicks goes from 0 to 1
{:noreply, socket} # => Re-render with updated counters
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<div>
<!-- => Div container wrapping component content -->
<h2>Counters</h2>
<!-- => H2 heading element -->
<%= for {key, value} <- @counters do %>
<!-- => Loops over @counters, binding each element to {key, value} -->
<div>
<!-- => Div container wrapping component content -->
Counter <%= key %>: <%= value %>
<!-- => Static text with interpolated Elixir expression -->
<button phx-click="increment" phx-value-counter={key}>+</button>
<!-- => Button triggers handle_event("increment", ...) on click -->
</div>
<!-- => Closes outer div container -->
<% end %>
<!-- => End of conditional/loop block -->
<p>Total Clicks: <%= @total_clicks %></p>
<!-- => Paragraph element displaying dynamic content -->
</div>
<!-- => Closes outer div container -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definitionKey Takeaway: Use update/3 to modify assigns based on their current value, ideal for counters, toggles, and nested data updates.
Why It Matters: The update/3 pattern is fundamental to correct concurrent state management. Reading and writing assigns separately creates a time-of-check-to-time-of-use race condition when multiple events fire rapidly. The update function receives the guaranteed current value and returns the new value atomically, eliminating this race. In production applications with high event frequency - real-time dashboards, games, collaborative editors - atomic state updates prevent subtle bugs that only appear under load. This functional transformation approach also makes state changes testable in isolation.
Example 44: Stream Collections
Use streams for efficiently rendering and updating large lists with automatic DOM diffing.
Stream update mechanism:
%% Stream DOM update flow
graph TD
A[stream_insert item] --> B[Item keyed by id]
B --> C[Client receives diff]
C --> D{Item exists?}
D -->|New| E[Insert into DOM]
D -->|Updated| F[Update DOM node]
D -->|Deleted| G[Remove from DOM]
style A fill:#0173B2,color:#fff
style B fill:#DE8F05,color:#fff
style C fill:#029E73,color:#fff
style E fill:#CA9161,color:#fff
style F fill:#CC78BC,color:#fff
style G fill:#CC78BC,color:#fff
Code:
defmodule MyAppWeb.TaskListLive do
# => Defines module MyAppWeb.TaskListLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
def mount(_params, _session, socket) do
# => Called on LiveView initialization
tasks = [
# => tasks bound to result of [
%{id: 1, title: "Task 1", completed: false},
# => Map with id: 1 — hardcoded sample data for demonstration
%{id: 2, title: "Task 2", completed: false}
# => Map with id: 2 — hardcoded sample data for demonstration
]
# => Closes list literal
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> stream(:tasks, tasks) # => Initialize stream with tasks
# => Stream tracks items by :id field
# => Client renders list keyed by item :id
{:ok, socket} # => Ready
# => Pattern: successful result — socket bound to returned value
end
# => Closes enclosing function/module/block definition
def handle_event("add_task", %{"title" => title}, socket) do
# => Handles "add_task" event from client
new_task = %{id: System.unique_integer([:positive]), title: title, completed: false}
# => new_task bound to result of %{id: System.unique_integer([:positive]), title: t
# => Create new task with unique ID
socket = stream_insert(socket, :tasks, new_task, at: 0) # => Prepend to stream
# => Only new task sent to client, existing tasks unchanged
{:noreply, socket} # => Efficient update
end
# => Closes enclosing function/module/block definition
def handle_event("delete_task", %{"id" => id_str}, socket) do
# => Handles "delete_task" event from client
id = String.to_integer(id_str) # => Convert to integer
# => Converts "123" to integer 123 — raises if not valid integer
socket = stream_delete_by_dom_id(socket, :tasks, "tasks-#{id}") # => Remove from stream
# => Only deletion sent to client
{:noreply, socket} # => Task removed from DOM
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<div>
<!-- => Div container wrapping component content -->
<h2>Task List</h2>
<!-- => H2 heading element -->
<form phx-submit="add_task">
<!-- => form HTML element -->
<input type="text" name="title" placeholder="New task" />
<!-- => text input field -->
<button>Add</button>
<!-- => Button element -->
</form>
<!-- => Closes form element — phx-submit/phx-change handlers deactivated -->
<%!-- Stream rendering with phx-update="stream" --%>
<ul id="tasks" phx-update="stream">
<!-- => List container for rendered items -->
<%= for {dom_id, task} <- @streams.tasks do %>
<!-- => Loops over @streams.tasks, binding each element to {dom_id, task} -->
<li id={dom_id}>
<!-- => List item rendered for each element -->
<%= task.title %>
<!-- => Evaluates Elixir expression and outputs result as HTML -->
<button phx-click="delete_task" phx-value-id={task.id}>Delete</button>
<!-- => Button triggers handle_event("delete_task", ...) on click -->
</li>
<!-- => Closes list item element -->
<% end %>
<!-- => End of conditional/loop block -->
</ul>
<!-- => Closes unordered list container -->
</div>
<!-- => Closes outer div container -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definitionKey Takeaway: Use stream/3 for large lists to enable efficient DOM updates - only changed items are sent to client, not entire list.
Why It Matters: Lists in LiveView are a performance challenge at scale. Sending full list HTML on every change becomes impractical when lists have thousands of items or update frequently. Streams solve this by maintaining a virtual list on the client and sending only diffs - new items, updated items, deleted items. In production applications like admin dashboards, activity feeds, or data grids, streams enable smooth real-time updates without the memory and bandwidth cost of full list re-rendering. The stream API also integrates with pagination and infinite scroll for complete list management.
Example 45: Reset Stream on Disconnect
Prevent stream memory leaks by resetting streams when clients disconnect.
Code:
defmodule MyAppWeb.ActivityFeedLive do
# => Defines module MyAppWeb.ActivityFeedLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
def mount(_params, _session, socket) do
# => Called on LiveView initialization
if connected?(socket) do
# => Returns true when WebSocket established
# Connected over WebSocket
Phoenix.PubSub.subscribe(MyApp.PubSub, "activities") # => Subscribe to updates
# => Subscribes process to broadcast topic — receives future broadcasts
activities = load_recent_activities() # => Load from database
# => activities = load_recent_activities() # => Load from
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> stream(:activities, activities, reset: true) # => Reset on reconnect
# => Clears any stale stream data from disconnection
{:ok, socket}
# => Returns success tuple to LiveView runtime
else
# => Else branch executes when condition was false
# Initial HTTP render (not connected yet)
{:ok, assign(socket, :activities_loaded, false)} # => Defer loading
# => Pattern: successful result — result bound to returned value
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definition
def handle_info({:new_activity, activity}, socket) do
# => Handles internal Elixir messages
# Received from PubSub
socket = stream_insert(socket, :activities, activity, at: 0) # => Prepend new activity
# => Inserts/updates item in stream — patches only changed DOM node
{:noreply, socket} # => Update feed
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<div>
<!-- => Div container wrapping component content -->
<h2>Activity Feed</h2>
<!-- => H2 heading element -->
<ul id="activities" phx-update="stream">
<!-- => List container for rendered items -->
<%= for {dom_id, activity} <- @streams.activities do %>
<!-- => Loops over @streams.activities, binding each element to {dom_id, activity} -->
<li id={dom_id}><%= activity.description %></li>
<!-- => List item rendered for each element -->
<% end %>
<!-- => End of conditional/loop block -->
</ul>
<!-- => Closes unordered list container -->
</div>
<!-- => Closes outer div container -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
defp load_recent_activities do
# => Defines load_recent_activities function
# Simulate database query
[
# => Opens list of metric definitions
%{id: 1, description: "User logged in"},
# => Map with id: 1 — hardcoded sample data for demonstration
%{id: 2, description: "New comment posted"}
# => Map with id: 2 — hardcoded sample data for demonstration
]
# => Closes list literal
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definitionKey Takeaway: Use reset: true with streams in mount when connected?/1 to prevent memory leaks from accumulated stream data during disconnections.
Why It Matters: WebSocket connections disconnect on mobile network changes, page hibernation, or server deployments. When a LiveView reconnects, its mount function runs again, creating fresh state. Without stream reset handling, reconnections can cause duplicate items because the client still has items from before disconnection while the server re-sends them. In production applications with streams, resetting on connected? ensures the client and server state are synchronized after reconnection. This prevents the confusing user experience of seeing duplicate entries or out-of-order items after network interruptions.
Example 46: Pagination with Streams
Implement efficient pagination using streams for large datasets.
Code:
defmodule MyAppWeb.ProductListLive do
# => Defines module MyAppWeb.ProductListLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
@page_size 20
# => Module-level constant @page_size = 20
def mount(_params, _session, socket) do
# => Called on LiveView initialization
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> assign(:page, 1) # => Current page (increments on scroll trigger)
# => socket.assigns.page = 1) # => Current page
|> load_products() # => Load first page
# => Pipes result into: load_products() # => Load first page
{:ok, socket} # => Ready
# => Pattern: successful result — socket bound to returned value
end
# => Closes enclosing function/module/block definition
def handle_event("load_next_page", _params, socket) do
# => Handles "load_next_page" event from client
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> update(:page, &(&1 + 1)) # => Increment page
# => Transforms assigns.page using current value via function
|> load_products() # => Load next page
# => Pipes result into: load_products() # => Load next page
{:noreply, socket} # => Append products to stream
end
# => Closes enclosing function/module/block definition
defp load_products(socket) do
# => Defines load_products function
page = socket.assigns.page
# => page bound to result of socket.assigns.page
offset = (page - 1) * @page_size # => Calculate offset
# => offset = (page - 1) * @page_size # => Calculate o
products = fetch_products(offset, @page_size) # => Query database
# => products: [%{id: 1, name: "Product 1"}, ...]
if page == 1 do
# => Branches on condition: executes inner block when page == 1 is truthy
# First page: initialize stream
stream(socket, :products, products) # => Create new stream
else
# => Else branch executes when condition was false
# Subsequent pages: append to stream
Enum.reduce(products, socket, fn product, acc ->
# => Accumulates result by applying function to each element
stream_insert(acc, :products, product, at: -1) # => Append to end
# => Inserts/updates item in stream — patches only changed DOM node
end)
# => Closes anonymous function; returns result to calling function
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<div>
<!-- => Div container wrapping component content -->
<h2>Products</h2>
<!-- => H2 heading element -->
<ul id="products" phx-update="stream">
<!-- => List container for rendered items -->
<%= for {dom_id, product} <- @streams.products do %>
<!-- => Loops over @streams.products, binding each element to {dom_id, product} -->
<li id={dom_id}><%= product.name %></li>
<!-- => List item rendered for each element -->
<% end %>
<!-- => End of conditional/loop block -->
</ul>
<!-- => Closes unordered list container -->
<button phx-click="load_next_page">Load More</button>
<!-- => Button triggers handle_event("load_next_page", ...) on click -->
</div>
<!-- => Closes outer div container -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
defp fetch_products(offset, limit) do
# => Defines fetch_products function
# Simulate database query
Enum.map((offset + 1)..(offset + limit), fn i ->
# => Transforms each element in list
%{id: i, name: "Product #{i}"}
# => Creates map with id: i — dynamically generated sample data
end)
# => Closes anonymous function; returns result to calling function
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definitionKey Takeaway: Combine streams with pagination to efficiently load and render large datasets, appending new pages without re-sending existing items.
Why It Matters: Loading all data up front is impractical for large datasets - a table with 10,000 rows should load the first 50 and let users page through. Combining streams with pagination loads initial data then appends subsequent pages as single stream batches. The client maintains its place in the data while the server fetches the next page on demand. In production applications, this pattern enables browsing large datasets without memory pressure on either client or server. It also integrates naturally with database query optimization - each page maps to a database LIMIT/OFFSET or cursor-based query.
Example 47: Infinite Scroll
Implement infinite scroll by detecting when user scrolls near bottom and loading more content.
Infinite scroll mechanism:
%% Infinite scroll with IntersectionObserver
sequenceDiagram
participant User
participant Hook as JS Hook
participant LiveView
User->>Hook: Scroll near bottom
Hook->>LiveView: push "load_more" event
LiveView->>LiveView: Fetch next page
LiveView->>Hook: stream_insert new items
Hook->>User: New items appear in DOM
Code:
defmodule MyAppWeb.InfiniteScrollLive do
# => Defines module MyAppWeb.InfiniteScrollLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
@page_size 20
# => Module-level constant @page_size = 20
def mount(_params, _session, socket) do
# => Called on LiveView initialization
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> assign(:page, 1)
# => Sets assigns.page
|> assign(:has_more, true) # => Track if more items available
# => socket.assigns.has_more = true) # => Track if more items available
|> load_page()
# => Pipes result into load_page()
{:ok, socket}
# => Returns success tuple to LiveView runtime
end
# => Closes enclosing function/module/block definition
def handle_event("load-more", _params, socket) do
# => Handles "load-more" event from client
if socket.assigns.has_more do
# => Branches on condition: executes inner block when socket.assigns.has_more is truthy
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> update(:page, &(&1 + 1))
# => Updates assign using current value
|> load_page()
# => Pipes result into load_page()
{:noreply, socket}
# => Returns updated socket, triggers re-render
else
# => Else branch executes when condition was false
{:noreply, socket} # => No more items
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definition
defp load_page(socket) do
# => Defines load_page function
page = socket.assigns.page
# => page bound to result of socket.assigns.page
items = fetch_items(page, @page_size)
# => items bound to result of fetch_items(page, @page_size)
has_more = length(items) == @page_size # => Check if more available
# => has_more = length(items) == @page_size # => Check i
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> assign(:has_more, has_more)
# => Sets assigns.has_more
|> then(fn socket ->
# => Pipes result into then(fn socket ->
if page == 1 do
# => Branches on condition: executes inner block when page == 1 is truthy
stream(socket, :items, items)
# => Initializes efficient stream for large collections
else
# => Else branch executes when condition was false
Enum.reduce(items, socket, fn item, acc ->
# => Accumulates result by applying function to each element
stream_insert(acc, :items, item, at: -1)
# => Inserts item into stream, triggers DOM patch
end)
# => Closes anonymous function; returns result to calling function
end
# => Closes enclosing function/module/block definition
end)
# => Closes anonymous function; returns result to calling function
socket
# => Starting socket as base for pipeline operations
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<div id="infinite-scroll-container" phx-hook="InfiniteScroll">
<!-- => Div container wrapping component content -->
<ul id="items" phx-update="stream">
<!-- => List container for rendered items -->
<%= for {dom_id, item} <- @streams.items do %>
<!-- => Loops over @streams.items, binding each element to {dom_id, item} -->
<li id={dom_id}><%= item.content %></li>
<!-- => List item rendered for each element -->
<% end %>
<!-- => End of conditional/loop block -->
</ul>
<!-- => Closes unordered list container -->
<%= if @has_more do %>
<!-- => Renders inner content only when @has_more is truthy -->
<div id="loading-trigger">Loading...</div>
<!-- => Div container wrapping component content -->
<% else %>
<!-- => Else branch — renders when condition is false -->
<div>No more items</div>
<!-- => Div container wrapping component content -->
<% end %>
<!-- => End of conditional/loop block -->
</div>
<!-- => Closes outer div container -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
defp fetch_items(page, limit) do
# => Defines fetch_items function
offset = (page - 1) * limit
# => offset bound to result of (page - 1) * limit
# Simulate limited dataset
if offset < 100 do
# => Branches on condition: executes inner block when offset < 100 is truthy
Enum.map((offset + 1)..min(offset + limit, 100), fn i ->
# => Transforms each element in list
%{id: i, content: "Item #{i}"}
# => Creates map with id: i — dynamically generated sample data
end)
# => Closes anonymous function; returns result to calling function
else
# => Else branch executes when condition was false
[] # => No more items
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definitionClient Hook (assets/js/app.js):
// InfiniteScroll hook detects when user scrolls near bottom
let Hooks = {};
Hooks.InfiniteScroll = {
mounted() {
this.observer = new IntersectionObserver(
(entries) => {
// => Triggered when loading-trigger becomes visible
if (entries[0].isIntersecting) {
this.pushEvent("load-more", {}); // => Request more items from server
}
},
{ threshold: 1.0 },
);
const trigger = document.getElementById("loading-trigger");
if (trigger) {
this.observer.observe(trigger); // => Watch loading-trigger element
}
},
destroyed() {
if (this.observer) {
this.observer.disconnect(); // => Cleanup
}
},
};Key Takeaway: Combine streams with IntersectionObserver client hook to automatically load more content when user scrolls near bottom.
Why It Matters: Infinite scroll provides a seamless browsing experience for content feeds, search results, and activity streams. Traditional pagination requires clicking buttons and waiting for new pages, breaking reading flow. The IntersectionObserver hook detects when the user scrolls near the bottom and triggers the next page load, appearing seamless to users. In production applications like social feeds, product listings, or analytics dashboards, infinite scroll with streams provides a smooth experience at scale. The hook cleanly separates client-side scroll detection from server-side data loading.
Example 48: Live Navigation - patch vs navigate
Understand the difference between patch (same LiveView) and navigate (different LiveView).
patch vs navigate decision:
%% Navigation type decision
graph TD
A[User navigates] --> B{Same LiveView?}
B -->|Yes| C[push_patch]
B -->|No| D[push_navigate]
C --> E[handle_params/3 called]
E --> F[LiveView process kept]
D --> G[New LiveView mounted]
G --> H[Old process terminated]
style A fill:#0173B2,color:#fff
style B fill:#DE8F05,color:#fff
style C fill:#029E73,color:#fff
style D fill:#CC78BC,color:#fff
style F fill:#CA9161,color:#fff
style H fill:#CC78BC,color:#fff
Code:
defmodule MyAppWeb.BlogLive do
# => Defines module MyAppWeb.BlogLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
def mount(_params, _session, socket) do
# => Called on LiveView initialization
{:ok, assign(socket, :posts, load_posts())} # => Load all posts
# => Pattern: successful result — result bound to returned value
end
# => Closes enclosing function/module/block definition
def handle_params(params, _uri, socket) do
# => Defines handle_params function
# Called on navigation and initial load
post_id = params["id"] # => Extract post ID from URL
# => post_id = params["id"] # => Extract post ID from U
selected_post = find_post(socket.assigns.posts, post_id) # => Find post by ID
# => selected_post = find_post(socket.assigns.posts, post_id)
socket = assign(socket, :selected_post, selected_post) # => Set selected post
# => socket.assigns.selected_post = selected_post
{:noreply, socket} # => Update view
end
# => Closes enclosing function/module/block definition
def handle_event("select_post", %{"id" => id}, socket) do
# => Handles "select_post" event from client
# patch keeps LiveView process alive, just updates params
{:noreply, push_patch(socket, to: "/blog?id=#{id}")} # => Update URL, call handle_params
# => Same LiveView process, no remount
end
# => Closes enclosing function/module/block definition
def handle_event("go_to_settings", _params, socket) do
# => Handles "go_to_settings" event from client
# navigate terminates current LiveView, starts new one
{:noreply, push_navigate(socket, to: "/settings")} # => Different LiveView
# => Current process terminates, new LiveView mounts
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<div>
<!-- => Div container wrapping component content -->
<h2>Blog Posts</h2>
<!-- => H2 heading element -->
<ul>
<!-- => List container for rendered items -->
<%= for post <- @posts do %>
<!-- => Loops over @posts, binding each element to post -->
<li>
<!-- => List item rendered for each element -->
<button phx-click="select_post" phx-value-id={post.id}>
<!-- => Button triggers handle_event("select_post", ...) on click -->
<%= post.title %>
<!-- => Evaluates Elixir expression and outputs result as HTML -->
</button>
<!-- => Closes button element -->
</li>
<!-- => Closes list item element -->
<% end %>
<!-- => End of conditional/loop block -->
</ul>
<!-- => Closes unordered list container -->
<%= if @selected_post do %>
<!-- => Renders inner content only when @selected_post is truthy -->
<div>
<!-- => Div container wrapping component content -->
<h3><%= @selected_post.title %></h3>
<!-- => H3 heading element -->
<p><%= @selected_post.content %></p>
<!-- => Paragraph element displaying dynamic content -->
</div>
<!-- => Closes outer div container -->
<% end %>
<!-- => End of conditional/loop block -->
<button phx-click="go_to_settings">Settings</button>
<!-- => Button triggers handle_event("go_to_settings", ...) on click -->
</div>
<!-- => Closes outer div container -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
defp load_posts do
# => Defines load_posts function
[
# => Opens list of metric definitions
%{id: 1, title: "Post 1", content: "Content 1"},
# => Map with id: 1 — hardcoded sample data for demonstration
%{id: 2, title: "Post 2", content: "Content 2"}
# => Map with id: 2 — hardcoded sample data for demonstration
]
# => Closes list literal
end
# => Closes enclosing function/module/block definition
defp find_post(posts, nil), do: nil
# => Defines find_post function
defp find_post(posts, id_str) do
# => Defines find_post function
id = String.to_integer(id_str)
# => Converts string to integer
Enum.find(posts, &(&1.id == id))
# => Finds first element matching predicate
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definitionKey Takeaway: Use push_patch/2 for navigation within same LiveView (keeps process alive), push_navigate/2 for different LiveViews (terminates current).
Why It Matters: Understanding patch vs navigate is critical for application architecture decisions. Push_patch keeps the LiveView process alive and calls handle_params, enabling fast in-place updates for filters, tabs, and pagination. Push_navigate terminates the current LiveView and mounts a new one, appropriate for moving between distinct features. Making the wrong choice can cause UX issues - patch for major feature changes means stale state persists, navigate for simple filters causes unnecessary process churn. In production applications, mapping user flows to the correct navigation type improves performance and maintains clean separation of concerns.
Example 49: Query Parameters
Handle URL query parameters for bookmarkable state and sharing.
Code:
defmodule MyAppWeb.SearchLive do
# => Defines module MyAppWeb.SearchLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
def mount(_params, _session, socket) do
# => Called on LiveView initialization
{:ok, assign(socket, :results, [])} # => Empty results initially
# => Pattern: successful result — result bound to returned value
end
# => Closes enclosing function/module/block definition
def handle_params(params, _uri, socket) do
# => Defines handle_params function
# Extract query parameters from URL
query = params["q"] || "" # => Search query
# => query = params["q"] || "" # => Search query
category = params["category"] || "all" # => Filter category
# => category = params["category"] || "all" # => Filter
page = params["page"] || "1" # => Pagination
# => page = params["page"] || "1" # => Pagination
results = perform_search(query, category, page) # => Search with params
# => results = perform_search(query, category, page) #
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> assign(:query, query)
# => Sets assigns.query
|> assign(:category, category)
# => Sets assigns.category
|> assign(:page, String.to_integer(page))
# => Sets assigns.page
|> assign(:results, results)
# => Sets assigns.results
{:noreply, socket} # => Render with query state
end
# => Closes enclosing function/module/block definition
def handle_event("search", %{"q" => query, "category" => category}, socket) do
# => Handles "search" event from client
# Update URL with new query parameters
{:noreply, push_patch(socket, to: "/search?q=#{query}&category=#{category}&page=1")}
# => Patches URL without full LiveView remount
# => URL updated, handle_params called with new params
end
# => Closes enclosing function/module/block definition
def handle_event("next_page", _params, socket) do
# => Handles "next_page" event from client
next_page = socket.assigns.page + 1
# => next_page bound to result of socket.assigns.page + 1
query = socket.assigns.query
# => query bound to result of socket.assigns.query
category = socket.assigns.category
# => category bound to result of socket.assigns.category
{:noreply, push_patch(socket, to: "/search?q=#{query}&category=#{category}&page=#{next_page}")}
# => Patches URL without full LiveView remount
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<div>
<!-- => Div container wrapping component content -->
<form phx-submit="search">
<!-- => form HTML element -->
<input type="text" name="q" value={@query} placeholder="Search..." />
<!-- => text input field -->
<select name="category">
<!-- => select HTML element -->
<option value="all" selected={@category == "all"}>All</option>
<!-- => option HTML element -->
<option value="products" selected={@category == "products"}>Products</option>
<!-- => option HTML element -->
<option value="articles" selected={@category == "articles"}>Articles</option>
<!-- => option HTML element -->
</select>
<button>Search</button>
<!-- => Button element -->
</form>
<!-- => Closes form element — phx-submit/phx-change handlers deactivated -->
<div>
<!-- => Div container wrapping component content -->
<h3>Results (Page <%= @page %>)</h3>
<!-- => H3 heading element -->
<ul>
<!-- => List container for rendered items -->
<%= for result <- @results do %>
<!-- => Loops over @results, binding each element to result -->
<li><%= result %></li>
<!-- => List item rendered for each element -->
<% end %>
<!-- => End of conditional/loop block -->
</ul>
<!-- => Closes unordered list container -->
<button phx-click="next_page">Next Page</button>
<!-- => Button triggers handle_event("next_page", ...) on click -->
</div>
<!-- => Closes outer div container -->
</div>
<!-- => Closes outer div container -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
defp perform_search(query, category, page) do
# => Defines perform_search function
# Simulate search
["Result for #{query} in #{category} (page #{page})"]
# => Returns simulated search results list
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definitionKey Takeaway: Use handle_params/3 to extract URL query parameters and push_patch/2 to update them, enabling bookmarkable and shareable state.
Why It Matters: URL query parameters enable bookmarkable, shareable application state. Users expect to copy a filtered view URL and have it reproduce the same results. LiveView's handle_params receives query parameters on every navigation and when the URL changes from push_patch. In production applications - product catalogs with filters, search results pages, analytics dashboards with date ranges - encoding state in URL parameters provides deep linking and browser history integration. This also enables server-side rendering of filtered views for SEO-sensitive pages.
Example 50: Flash Messages
Display temporary success/error messages using flash assigns.
Code:
defmodule MyAppWeb.TaskFormLive do
# => Defines module MyAppWeb.TaskFormLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
def mount(_params, _session, socket) do
# => Called on LiveView initialization
{:ok, assign(socket, :task_title, "")} # => Empty form
# => Pattern: successful result — result bound to returned value
end
# => Closes enclosing function/module/block definition
def handle_event("save_task", %{"title" => title}, socket) do
# => Handles "save_task" event from client
case save_task(title) do
# => Pattern matches on result value
{:ok, _task} ->
# => Matches this pattern — executes right-hand side
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> put_flash(:info, "Task created successfully!") # => Sets @flash[:info] in socket
# => Pipes result into: put_flash(:info, "Task created successfully!"
|> assign(:task_title, "") # => Clear form
# => socket.assigns.task_title = "") # => Clear form
{:noreply, socket} # => Flash shown at top
{:error, reason} ->
# => Pattern: error result — reason bound to error reason
socket = put_flash(socket, :error, "Failed to create task: #{reason}") # => Error flash
# => socket variable updated with new state
{:noreply, socket} # => Error shown at top
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definition
def handle_event("clear_flash", _params, socket) do
# => Handles "clear_flash" event from client
socket = clear_flash(socket) # => Remove all flash messages
# => socket variable updated with new state
{:noreply, socket} # => Flash cleared
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<div>
<!-- => Div container wrapping component content -->
<%!-- Flash messages displayed at top --%>
<div :if={@flash["info"]} class="alert alert-info">
<!-- => Div container with class="alert alert-info" -->
<%= @flash["info"] %>
<!-- => Outputs assigns.flash value as HTML -->
<button phx-click="clear_flash">×</button>
<!-- => Button triggers handle_event("clear_flash", ...) on click -->
</div>
<!-- => Closes outer div container -->
<div :if={@flash["error"]} class="alert alert-error">
<!-- => Div container with class="alert alert-error" -->
<%= @flash["error"] %>
<!-- => Outputs assigns.flash value as HTML -->
<button phx-click="clear_flash">×</button>
<!-- => Button triggers handle_event("clear_flash", ...) on click -->
</div>
<!-- => Closes outer div container -->
<form phx-submit="save_task">
<!-- => form HTML element -->
<input type="text" name="title" value={@task_title} placeholder="Task title" />
<!-- => text input field -->
<button>Save</button>
<!-- => Button element -->
</form>
<!-- => Closes form element — phx-submit/phx-change handlers deactivated -->
</div>
<!-- => Closes outer div container -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
defp save_task(""), do: {:error, "Title cannot be empty"}
# => Defines save_task function
defp save_task(title) do
# => Defines save_task function
# Simulate save
{:ok, %{id: 1, title: title}}
# => Pattern: successful result — result bound to returned value
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definitionKey Takeaway: Use put_flash/3 to set temporary messages and access via @flash in templates for user feedback without persistent state.
Why It Matters: Flash messages provide ephemeral feedback for operations that complete outside the user's immediate view - form submissions, background jobs, redirects. LiveView's put_flash integrates with the Phoenix session flash system, so messages persist across redirects and LiveView mounts. In production applications, flash messages communicate success (payment processed), warnings (quota nearly exceeded), and errors (upload failed) without permanent state changes. The @flash assign is automatically cleared after display, preventing stale messages from persisting across unrelated user actions.
PubSub and Real-time (Examples 51-55)
Phoenix.PubSub enables real-time multi-user synchronization through publish/subscribe messaging.
Example 51: Phoenix.PubSub Basics
Use Phoenix.PubSub to broadcast messages between LiveView processes.
PubSub broadcast flow:
%% PubSub message flow
sequenceDiagram
participant UserA as User A LiveView
participant PubSub
participant UserB as User B LiveView
UserA->>PubSub: broadcast("topic", message)
PubSub->>UserA: handle_info (self)
PubSub->>UserB: handle_info
UserA->>UserA: Update assigns, re-render
UserB->>UserB: Update assigns, re-render
Code:
defmodule MyAppWeb.ChatRoomLive do
# => Defines module MyAppWeb.ChatRoomLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
@topic "chat:lobby"
# => Module-level constant @topic = "chat:lobby"
def mount(_params, _session, socket) do
# => Called on LiveView initialization
if connected?(socket) do
# => Returns true when WebSocket established
# Subscribe to chat topic when connected via WebSocket
Phoenix.PubSub.subscribe(MyApp.PubSub, @topic) # => Listen for broadcasts
# => Any broadcast to "chat:lobby" received in handle_info
end
# => Closes enclosing function/module/block definition
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> assign(:messages, []) # => Empty message list
# => socket.assigns.messages = []) # => Empty message list
|> assign(:username, "User#{:rand.uniform(1000)}") # => Random username
# => socket.assigns.username = "User#{:rand.uniform(1000)}") # => Rando
{:ok, socket} # => Ready
# => Pattern: successful result — socket bound to returned value
end
# => Closes enclosing function/module/block definition
def handle_event("send_message", %{"text" => text}, socket) do
# => Handles "send_message" event from client
message = %{
# => message bound to result of %{
username: socket.assigns.username,
# => Sets username: field to socket.assigns.username
text: text,
# => Sets text: field to text
timestamp: DateTime.utc_now()
# => Sets timestamp: field to DateTime.utc_now()
}
# Broadcast to all subscribers
Phoenix.PubSub.broadcast(MyApp.PubSub, @topic, {:new_message, message})
# => Broadcasts message to all subscribers
# => Sent to all LiveView processes subscribed to "chat:lobby"
# => Including this process (will receive in handle_info)
{:noreply, socket} # => Don't update yet (wait for broadcast)
end
# => Closes enclosing function/module/block definition
def handle_info({:new_message, message}, socket) do
# => Handles internal Elixir messages
# Received broadcast from any process (including self)
socket = update(socket, :messages, fn messages ->
# => Updates assigns.messages using current value
[message | messages] # => Prepend new message
end)
# => Closes anonymous function; returns result to calling function
{:noreply, socket} # => Re-render with new message
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<div>
<!-- => Div container wrapping component content -->
<h2>Chat Room</h2>
<!-- => H2 heading element -->
<div id="messages">
<!-- => Div container wrapping component content -->
<%= for msg <- Enum.reverse(@messages) do %>
<!-- => Loops over Enum.reverse(@messages), binding each element to msg -->
<p><strong><%= msg.username %>:</strong> <%= msg.text %></p>
<!-- => Paragraph element displaying dynamic content -->
<% end %>
<!-- => End of conditional/loop block -->
</div>
<!-- => Closes outer div container -->
<form phx-submit="send_message">
<!-- => form HTML element -->
<input type="text" name="text" placeholder="Type a message..." />
<!-- => text input field -->
<button>Send</button>
<!-- => Button element -->
</form>
<!-- => Closes form element — phx-submit/phx-change handlers deactivated -->
</div>
<!-- => Closes outer div container -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definitionKey Takeaway: Use Phoenix.PubSub.subscribe/2 to listen and broadcast/3 to publish messages, enabling real-time multi-user features.
Why It Matters: Real-time updates are a key value proposition of Phoenix LiveView. PubSub enables any LiveView to receive updates when data changes anywhere in the system - another user's action, a background job completion, an external webhook. Without PubSub, LiveViews are isolated islands that only update on direct user interaction. In production applications - live dashboards, collaborative tools, notification systems - PubSub transforms LiveView from a real-time UI into a genuinely reactive application that reflects current system state to all connected users simultaneously.
Example 52: Subscribe to Multiple Topics
Subscribe to multiple PubSub topics to receive updates from different sources.
Code:
defmodule MyAppWeb.DashboardLive do
# => Defines module MyAppWeb.DashboardLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
def mount(_params, _session, socket) do
# => Called on LiveView initialization
if connected?(socket) do
# => Returns true when WebSocket established
# Subscribe to multiple topics
Phoenix.PubSub.subscribe(MyApp.PubSub, "users:activity") # => User events
# => Subscribes process to broadcast topic — receives future broadcasts
Phoenix.PubSub.subscribe(MyApp.PubSub, "orders:new") # => New orders
# => Subscribes process to broadcast topic — receives future broadcasts
Phoenix.PubSub.subscribe(MyApp.PubSub, "system:alerts") # => System alerts
# => All three topics will send messages to this process
end
# => Closes enclosing function/module/block definition
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> assign(:recent_activities, [])
# => Sets assigns.recent_activities
|> assign(:new_orders_count, 0)
# => Sets assigns.new_orders_count
|> assign(:alerts, [])
# => Sets assigns.alerts
{:ok, socket} # => Ready
# => Pattern: successful result — socket bound to returned value
end
# => Closes enclosing function/module/block definition
def handle_info({:user_activity, activity}, socket) do
# => Handles internal Elixir messages
# From "users:activity" topic
socket = update(socket, :recent_activities, fn activities ->
# => Updates assigns.recent_activities using current value
[activity | Enum.take(activities, 9)] # => Keep last 10 activities
end)
# => Closes anonymous function; returns result to calling function
{:noreply, socket}
# => Returns updated socket, triggers re-render
end
# => Closes enclosing function/module/block definition
def handle_info({:new_order, _order}, socket) do
# => Handles internal Elixir messages
# From "orders:new" topic
socket = update(socket, :new_orders_count, &(&1 + 1)) # => Increment counter
# => Updates assigns.new_orders_count: passes current value to function
{:noreply, socket}
# => Returns updated socket, triggers re-render
end
# => Closes enclosing function/module/block definition
def handle_info({:system_alert, alert}, socket) do
# => Handles internal Elixir messages
# From "system:alerts" topic
socket = update(socket, :alerts, fn alerts ->
# => Updates assigns.alerts using current value
[alert | alerts] # => Add alert to list
end)
# => Closes anonymous function; returns result to calling function
{:noreply, socket}
# => Returns updated socket, triggers re-render
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<div>
<!-- => Div container wrapping component content -->
<h2>Dashboard</h2>
<!-- => H2 heading element -->
<div class="panel">
<!-- => Div container with class="panel" -->
<h3>Recent Activity</h3>
<!-- => H3 heading element -->
<ul>
<!-- => List container for rendered items -->
<%= for activity <- @recent_activities do %>
<!-- => Loops over @recent_activities, binding each element to activity -->
<li><%= activity %></li>
<!-- => List item rendered for each element -->
<% end %>
<!-- => End of conditional/loop block -->
</ul>
<!-- => Closes unordered list container -->
</div>
<!-- => Closes outer div container -->
<div class="panel">
<!-- => Div container with class="panel" -->
<h3>New Orders</h3>
<!-- => H3 heading element -->
<p><%= @new_orders_count %> new orders</p>
<!-- => Paragraph element displaying dynamic content -->
</div>
<!-- => Closes outer div container -->
<div class="panel">
<!-- => Div container with class="panel" -->
<h3>System Alerts</h3>
<!-- => H3 heading element -->
<%= for alert <- @alerts do %>
<!-- => Loops over @alerts, binding each element to alert -->
<div class="alert"><%= alert %></div>
<!-- => Div container with class="alert" -->
<% end %>
<!-- => End of conditional/loop block -->
</div>
<!-- => Closes outer div container -->
</div>
<!-- => Closes outer div container -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definitionKey Takeaway: Pattern match on different message types in handle_info/2 to handle updates from multiple PubSub topics in a single LiveView.
Why It Matters: Production applications involve multiple overlapping concerns - a user might need notifications about their messages, their project's status updates, and system announcements simultaneously. Subscribing to multiple PubSub topics allows a single LiveView to aggregate these streams without coupling the broadcaster to the subscriber. In production notification systems, this enables fine-grained subscriptions where users see only relevant updates, and decouples the broadcast logic from display logic. Pattern matching on the message source in handle_info allows different handling for different topic types.
Example 53: Broadcast Updates
Broadcast state changes to all connected users for real-time synchronization.
Write-then-broadcast pattern:
%% Broadcast on write pattern
graph TD
A[User event] --> B[handle_event]
B --> C[Persist to DB]
C --> D[broadcast to topic]
D --> E[All subscribers]
E --> F[handle_info update]
F --> G[Re-render for each viewer]
style A fill:#0173B2,color:#fff
style C fill:#DE8F05,color:#fff
style D fill:#029E73,color:#fff
style E fill:#CC78BC,color:#fff
style G fill:#CA9161,color:#fff
Code:
defmodule MyAppWeb.DocumentEditorLive do
# => Defines module MyAppWeb.DocumentEditorLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
def mount(%{"doc_id" => doc_id}, _session, socket) do
# => Defines mount function
topic = "document:#{doc_id}" # => Topic per document
# => topic = "document:#{doc_id}" # => Topic per docu
if connected?(socket) do
# => Returns true when WebSocket established
Phoenix.PubSub.subscribe(MyApp.PubSub, topic) # => Subscribe to this document
# => Receives broadcasts from any user editing this document
# => Subscribes process to broadcast topic — receives future broadcasts
end
# => Closes enclosing function/module/block definition
document = load_document(doc_id) # => Load from database
# => document = load_document(doc_id) # => Load from dat
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> assign(:doc_id, doc_id)
# => Sets assigns.doc_id
|> assign(:topic, topic)
# => Sets assigns.topic
|> assign(:content, document.content)
# => Sets assigns.content
|> assign(:active_users, 1) # => This user
# => socket.assigns.active_users = 1) # => This user
{:ok, socket}
# => Returns success tuple to LiveView runtime
end
# => Closes enclosing function/module/block definition
def handle_event("update_content", %{"content" => new_content}, socket) do
# => Handles "update_content" event from client
# User edited content
doc_id = socket.assigns.doc_id
# => doc_id bound to result of socket.assigns.doc_id
save_document(doc_id, new_content) # => Persist to database
# Broadcast to all other users editing this document
Phoenix.PubSub.broadcast(
# => Broadcasts message to all subscribers
MyApp.PubSub,
# => PubSub module for message routing
socket.assigns.topic,
# => Reads socket.assigns.topic value
{:content_updated, new_content}
# => Message tuple: {content_updated, new_content} matches in handle_info
)
# => All subscribers (except self if using broadcast_from) receive update
socket = assign(socket, :content, new_content) # => Update local state
# => socket.assigns.content = new_content
{:noreply, socket}
# => Returns updated socket, triggers re-render
end
# => Closes enclosing function/module/block definition
def handle_info({:content_updated, new_content}, socket) do
# => Handles internal Elixir messages
# Another user updated content
socket = assign(socket, :content, new_content) # => Sync content
# => socket.assigns.content = new_content
{:noreply, socket} # => Display updated content
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<div>
<!-- => Div container wrapping component content -->
<h2>Document Editor (Doc <%= @doc_id %>)</h2>
<!-- => H2 heading element -->
<p><%= @active_users %> active users</p>
<!-- => Paragraph element displaying dynamic content -->
<form phx-change="update_content">
<!-- => form HTML element -->
<textarea name="content" rows="20" cols="80"><%= @content %></textarea>
<!-- => textarea HTML element -->
</form>
<!-- => Closes form element — phx-submit/phx-change handlers deactivated -->
<p class="hint">Changes sync in real-time to all users</p>
<!-- => Paragraph element displaying dynamic content -->
</div>
<!-- => Closes outer div container -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
defp load_document(_id), do: %{content: "Initial content"}
# => Defines load_document function
defp save_document(_id, content), do: IO.puts("Saved: #{content}")
# => Defines save_document function
end
# => Closes enclosing function/module/block definitionKey Takeaway: Broadcast updates to document-specific topics to synchronize state across all users viewing the same resource in real-time.
Why It Matters: Broadcast on write is the pattern that makes multi-user applications feel real-time. When one user makes a change, every other user viewing the same data receives an update immediately without polling. In production applications - live documents, shared dashboards, project management tools - broadcasting after mutations ensures all clients stay synchronized. The pattern also applies to non-user events: background jobs can broadcast completion, IoT sensors can broadcast readings, webhooks can broadcast received data. PubSub becomes the nervous system of the application.
Example 54: handle_info for PubSub Messages
Use handle_info/2 to receive and process PubSub messages in LiveView.
Code:
defmodule MyAppWeb.NotificationLive do
# => Defines module MyAppWeb.NotificationLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
def mount(%{"user_id" => user_id}, _session, socket) do
# => Defines mount function
if connected?(socket) do
# => Returns true when WebSocket established
# Subscribe to user-specific notifications
Phoenix.PubSub.subscribe(MyApp.PubSub, "notifications:#{user_id}")
# => Subscribes to real-time broadcast topic
end
# => Closes enclosing function/module/block definition
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> assign(:notifications, [])
# => Sets assigns.notifications
|> assign(:unread_count, 0)
# => Sets assigns.unread_count
{:ok, socket}
# => Returns success tuple to LiveView runtime
end
# => Closes enclosing function/module/block definition
def handle_info({:notification, notification}, socket) do
# => Handles internal Elixir messages
# Received notification from PubSub
# notification: %{id: 1, title: "...", body: "...", read: false}
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> update(:notifications, fn notifications ->
# => Updates assign using current value
[notification | notifications] # => Prepend notification
end)
# => Closes anonymous function; returns result to calling function
|> update(:unread_count, &(&1 + 1)) # => Increment unread
# => Transforms assigns.unread_count using current value via function
{:noreply, socket} # => Display notification
end
# => Closes enclosing function/module/block definition
def handle_info({:notification_read, notification_id}, socket) do
# => Handles internal Elixir messages
# Another client marked notification as read
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> update(:notifications, fn notifications ->
# => Updates assign using current value
Enum.map(notifications, fn notif ->
# => Transforms each element in list
if notif.id == notification_id do
# => Branches on condition: executes inner block when notif.id == notification_id is truthy
%{notif | read: true} # => Mark as read
else
# => Else branch executes when condition was false
notif
# => notif piped into following operations
end
# => Closes enclosing function/module/block definition
end)
# => Closes anonymous function; returns result to calling function
end)
# => Closes anonymous function; returns result to calling function
|> update(:unread_count, &max(&1 - 1, 0)) # => Decrement unread
# => Transforms assigns.unread_count using current value via function
{:noreply, socket} # => Update UI
end
# => Closes enclosing function/module/block definition
def handle_event("mark_read", %{"id" => id_str}, socket) do
# => Handles "mark_read" event from client
notification_id = String.to_integer(id_str)
# => Converts string to integer
# Broadcast to sync across user's devices
Phoenix.PubSub.broadcast(
# => Broadcasts message to all subscribers
MyApp.PubSub,
# => PubSub module for message routing
"notifications:#{socket.assigns.user_id}",
# => Topic string for PubSub subscription/broadcast
{:notification_read, notification_id}
# => Message tuple: {notification_read, notification_id} matches in handle_info
)
# => Closes multi-line function call
{:noreply, socket} # => Will receive broadcast in handle_info
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<div>
<!-- => Div container wrapping component content -->
<h2>Notifications (<%= @unread_count %> unread)</h2>
<!-- => H2 heading element -->
<ul>
<!-- => List container for rendered items -->
<%= for notif <- @notifications do %>
<!-- => Loops over @notifications, binding each element to notif -->
<li class={if notif.read, do: "read", else: "unread"}>
<!-- => List item rendered for each element -->
<strong><%= notif.title %></strong>: <%= notif.body %>
<!-- => strong HTML element -->
<%= unless notif.read do %>
<!-- => Renders inner content only when notif.read is falsy -->
<button phx-click="mark_read" phx-value-id={notif.id}>Mark Read</button>
<!-- => Button triggers handle_event("mark_read", ...) on click -->
<% end %>
<!-- => End of conditional/loop block -->
</li>
<!-- => Closes list item element -->
<% end %>
<!-- => End of conditional/loop block -->
</ul>
<!-- => Closes unordered list container -->
</div>
<!-- => Closes outer div container -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definitionKey Takeaway: Pattern match on message tuples in handle_info/2 to handle different PubSub message types with distinct processing logic.
Why It Matters: handle_info is the generic message receiver that makes LiveView processes first-class Erlang/OTP processes. PubSub, Process.send_after for timers, Task completions, GenServer messages - all arrive as messages handled by handle_info. In production LiveViews with complex async behavior, pattern matching on message tuples in handle_info becomes the coordination layer between background work and UI updates. Understanding this callback is essential for building LiveViews that respond to system events beyond user interactions.
Example 55: Multi-user Synchronization
Synchronize state across multiple users in real-time using presence tracking and broadcasts.
Multi-user synchronization architecture:
%% Multi-user real-time sync
graph TD
A[User A Edit] --> B[broadcast document:1]
C[User B Edit] --> B
B --> D[handle_info User A LiveView]
B --> E[handle_info User B LiveView]
B --> F[handle_info User C LiveView]
D --> G[Update UI]
E --> H[Update UI]
F --> I[Update UI]
style A fill:#0173B2,color:#fff
style B fill:#029E73,color:#fff
style C fill:#DE8F05,color:#fff
style D fill:#CC78BC,color:#fff
style E fill:#CC78BC,color:#fff
style F fill:#CC78BC,color:#fff
Code:
defmodule MyAppWeb.WhiteboardLive do
# => Defines module MyAppWeb.WhiteboardLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
alias Phoenix.PubSub
# => Aliases Phoenix.PubSub for shorter references
@topic "whiteboard:shared"
# => Module-level constant @topic = "whiteboard:shared"
def mount(_params, _session, socket) do
# => Called on LiveView initialization
if connected?(socket) do
# => Returns true when WebSocket established
PubSub.subscribe(MyApp.PubSub, @topic) # => Subscribe to whiteboard updates
# => Subscribes process to broadcast topic — receives future broadcasts
# Announce presence
user_id = "user_#{:rand.uniform(1000)}"
# => user_id bound to result of "user_#{:rand.uniform(1000)}"
PubSub.broadcast(MyApp.PubSub, @topic, {:user_joined, user_id})
# => Broadcasts message to all subscribers
end
# => Closes enclosing function/module/block definition
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> assign(:shapes, []) # => Drawn shapes
# => socket.assigns.shapes = []) # => Drawn shapes
|> assign(:active_users, []) # => List of active users
# => socket.assigns.active_users = []) # => List of active users
{:ok, socket}
# => Returns success tuple to LiveView runtime
end
# => Closes enclosing function/module/block definition
def handle_event("draw_shape", %{"x" => x, "y" => y, "type" => type}, socket) do
# => Handles "draw_shape" event from client
shape = %{id: System.unique_integer([:positive]), x: x, y: y, type: type}
# => shape bound to result of %{id: System.unique_integer([:positive]), x: x, y:
# Broadcast to all users
PubSub.broadcast(MyApp.PubSub, @topic, {:shape_drawn, shape})
# => Broadcasts message to all subscribers
{:noreply, socket} # => Will receive broadcast
end
# => Closes enclosing function/module/block definition
def handle_info({:shape_drawn, shape}, socket) do
# => Handles internal Elixir messages
# Another user drew a shape
socket = update(socket, :shapes, fn shapes ->
# => Updates assigns.shapes using current value
[shape | shapes] # => Add to whiteboard
end)
# => Closes anonymous function; returns result to calling function
{:noreply, socket} # => Render shape
end
# => Closes enclosing function/module/block definition
def handle_info({:user_joined, user_id}, socket) do
# => Handles internal Elixir messages
socket = update(socket, :active_users, fn users ->
# => Updates assigns.active_users using current value
[user_id | users] # => Add user
end)
# => Closes anonymous function; returns result to calling function
{:noreply, socket} # => Update user count
end
# => Closes enclosing function/module/block definition
def handle_info({:user_left, user_id}, socket) do
# => Handles internal Elixir messages
socket = update(socket, :active_users, fn users ->
# => Updates assigns.active_users using current value
List.delete(users, user_id) # => Remove user
end)
# => Closes anonymous function; returns result to calling function
{:noreply, socket} # => Update user count
end
# => Closes enclosing function/module/block definition
def terminate(_reason, socket) do
# => Called before LiveView process exits
# User disconnected
user_id = socket.assigns[:user_id]
# => user_id bound to result of socket.assigns[:user_id]
if user_id do
# => Branches on condition: executes inner block when user_id is truthy
PubSub.broadcast(MyApp.PubSub, @topic, {:user_left, user_id})
# => Broadcasts message to all subscribers
end
# => Closes enclosing function/module/block definition
:ok
# => Returns :ok atom — signals successful completion
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<div>
<!-- => Div container wrapping component content -->
<h2>Collaborative Whiteboard</h2>
<!-- => H2 heading element -->
<p><%= length(@active_users) %> active users</p>
<!-- => Paragraph element displaying dynamic content -->
<div id="canvas" phx-click="draw_shape" phx-value-type="circle" style="border: 1px solid black; width: 600px; height: 400px; position: relative;">
<!-- => Div container with phx-click='draw_shape' -->
<%= for shape <- @shapes do %>
<!-- => Loops over @shapes, binding each element to shape -->
<div style={"position: absolute; left: #{shape.x}px; top: #{shape.y}px; width: 20px; height: 20px; border-radius: 50%; background: blue;"}></div>
<!-- => Div container wrapping component content -->
<% end %>
<!-- => End of conditional/loop block -->
</div>
<!-- => Closes outer div container -->
</div>
<!-- => Closes outer div container -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definitionKey Takeaway: Combine PubSub broadcasts with presence tracking (user_joined, user_left) to build real-time collaborative applications with multi-user synchronization.
Why It Matters: Multi-user real-time collaboration requires more than just broadcasting - it requires awareness of who else is present and handling concurrent edits gracefully. Phoenix Presence tracks connected users and provides join/leave notifications, while PubSub handles data synchronization. In production collaborative applications - shared documents, multi-player features, live customer support - presence awareness creates social context that makes collaboration feel natural. Users see who is online, who is typing, and who just made a change, creating the sense of shared space that defines collaborative applications.
File Uploads (Examples 56-60)
File uploads in LiveView provide progress tracking, validation, and efficient handling of uploaded content.
Example 56: Upload Configuration
Configure uploads with validation rules using allow_upload/3.
Code:
defmodule MyAppWeb.FileUploadLive do
# => Defines module MyAppWeb.FileUploadLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
def mount(_params, _session, socket) do
# => Called on LiveView initialization
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> assign(:uploaded_files, []) # => Track uploads
# => socket.assigns.uploaded_files = []) # => Track uploads
|> allow_upload(:documents, # => Named upload — :documents
# => Configures documents upload: sets file type, size, count limits
accept: ~w(.pdf .doc .docx), # => Allowed file types
max_entries: 5, # => Maximum 5 files at once
max_file_size: 10_000_000, # => 10MB per file
auto_upload: false # => Manual upload control
)
# => Upload config stored in socket.assigns.uploads.documents
{:ok, socket}
# => Returns success tuple to LiveView runtime
end
# => Closes enclosing function/module/block definition
def handle_event("validate", _params, socket) do
# => Handles "validate" event from client
# Automatic validation based on allow_upload configuration
# Errors appear in @uploads.documents.errors
{:noreply, socket} # => Display validation errors
end
# => Closes enclosing function/module/block definition
def handle_event("upload", _params, socket) do
# => Handles "upload" event from client
# Process validated uploads
uploaded_files =
# => Binds result to variable
consume_uploaded_entries(socket, :documents, fn %{path: path}, entry ->
# => Process each completed upload from :documents input
# => Processes uploaded files, returns results list
# path: temporary server path
# entry: %{client_name: "file.pdf", content_type: "application/pdf"}
dest = Path.join("priv/static/uploads", entry.client_name)
# => dest bound to result of Path.join("priv/static/uploads", entry.client_name
File.cp!(path, dest) # => Copy to permanent location
{:ok, "/uploads/#{entry.client_name}"} # => Return public URL
# => Pattern: successful result — result bound to returned value
end)
# => Closes anonymous function; returns result to calling function
socket = assign(socket, :uploaded_files, uploaded_files) # => Store URLs
# => socket.assigns.uploaded_files = uploaded_files
{:noreply, socket} # => Display uploaded files
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<div>
<!-- => Div container wrapping component content -->
<h2>File Upload</h2>
<!-- => H2 heading element -->
<form phx-change="validate" phx-submit="upload">
<!-- => form HTML element -->
<.live_file_input upload={@uploads.documents} />
<!-- => element HTML element -->
<%!-- Show global upload errors --%>
<%= for err <- @uploads.documents.errors do %>
<!-- => Loops over @uploads.documents.errors, binding each element to err -->
<p class="error"><%= error_to_string(err) %></p>
<!-- => Paragraph element displaying dynamic content -->
<% end %>
<!-- => End of conditional/loop block -->
<%!-- Show per-entry progress and errors --%>
<%= for entry <- @uploads.documents.entries do %>
<!-- => Loops over @uploads.documents.entries, binding each element to entry -->
<div>
<!-- => Div container wrapping component content -->
<p><%= entry.client_name %> (<%= entry.progress %>%)</p>
<!-- => Paragraph element displaying dynamic content -->
<%= for err <- upload_errors(@uploads.documents, entry) do %>
<!-- => Loops over upload_errors(@uploads.documen, binding each element to err -->
<p class="error"><%= error_to_string(err) %></p>
<!-- => Paragraph element displaying dynamic content -->
<% end %>
<!-- => End of conditional/loop block -->
</div>
<!-- => Closes outer div container -->
<% end %>
<!-- => End of conditional/loop block -->
<button type="submit">Upload</button>
<!-- => Submit button — triggers phx-submit form event -->
</form>
<!-- => Closes form element — phx-submit/phx-change handlers deactivated -->
<h3>Uploaded Files</h3>
<!-- => H3 heading element -->
<ul>
<!-- => List container for rendered items -->
<%= for file <- @uploaded_files do %>
<!-- => Loops over @uploaded_files, binding each element to file -->
<li><a href={file}><%= file %></a></li>
<!-- => List item rendered for each element -->
<% end %>
<!-- => End of conditional/loop block -->
</ul>
<!-- => Closes unordered list container -->
</div>
<!-- => Closes outer div container -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
defp error_to_string(:too_large), do: "File too large (max 10MB)"
# => Defines error_to_string function
defp error_to_string(:too_many_files), do: "Too many files (max 5)"
# => Defines error_to_string function
defp error_to_string(:not_accepted), do: "Invalid file type (pdf, doc, docx only)"
# => Defines error_to_string function
end
# => Closes enclosing function/module/block definitionKey Takeaway: Configure uploads with allow_upload/3 specifying accepted types, size limits, and max entries for automatic validation.
Why It Matters: Upload configuration is a security boundary. Without explicit accept types, any file can be uploaded. Without size limits, large files can exhaust disk and memory. Without max_entries limits, users can attach hundreds of files at once. LiveView's allow_upload makes these constraints declarative and enforces them before the upload begins, providing client-side feedback immediately. In production file upload features - document management, media libraries, email attachments - proper configuration prevents storage abuse, reduces attack surface, and creates predictable resource usage that won't surprise you at scale.
Example 57: Progress Tracking
Track upload progress in real-time and display to users.
Upload progress flow:
%% File upload progress lifecycle
sequenceDiagram
participant Browser
participant LiveView
Browser->>LiveView: Start upload (chunked)
loop For each chunk
Browser->>LiveView: Chunk data
LiveView->>Browser: entry.progress update
end
Browser->>LiveView: Upload complete
LiveView->>Browser: consume_uploaded_entries result
Code:
defmodule MyAppWeb.ProgressUploadLive do
# => Defines module MyAppWeb.ProgressUploadLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
def mount(_params, _session, socket) do
# => Called on LiveView initialization
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> assign(:uploaded_files, [])
# => Sets assigns.uploaded_files
|> allow_upload(:photos, # => Configure :photos upload independently
# => Configures file upload parameters
accept: ~w(.jpg .jpeg .png .gif),
# => Allowed file extensions: .jpg .jpeg .png .gif
max_entries: 10,
# => Maximum 10 file(s) allowed per upload
max_file_size: 5_000_000,
# => Maximum file size: 0MB (5 bytes)
chunk_size: 64_000 # => Upload in 64KB chunks for progress tracking
)
# => Closes multi-line function call
{:ok, socket}
# => Returns success tuple to LiveView runtime
end
# => Closes enclosing function/module/block definition
def handle_event("validate", _params, socket) do
# => Handles "validate" event from client
{:noreply, socket} # => Validation automatic
end
# => Closes enclosing function/module/block definition
def handle_event("upload", _params, socket) do
# => Handles "upload" event from client
uploaded_files =
# => Binds result to variable
consume_uploaded_entries(socket, :photos, fn %{path: path}, entry ->
# => Processes uploaded files, returns results list
dest = Path.join("priv/static/uploads/photos", entry.client_name)
# => dest bound to result of Path.join("priv/static/uploads/photos", entry.clie
File.cp!(path, dest)
# => Copies uploaded temp file to permanent destination path
{:ok, %{url: "/uploads/photos/#{entry.client_name}", name: entry.client_name}}
# => Pattern: successful result — result bound to returned value
end)
# => Closes anonymous function; returns result to calling function
socket = update(socket, :uploaded_files, fn files -> files ++ uploaded_files end)
# => Updates assigns.uploaded_files using current value
{:noreply, socket}
# => Returns updated socket, triggers re-render
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<div>
<!-- => Div container wrapping component content -->
<h2>Photo Upload with Progress</h2>
<!-- => H2 heading element -->
<form phx-change="validate" phx-submit="upload">
<!-- => form HTML element -->
<.live_file_input upload={@uploads.photos} />
<!-- => element HTML element -->
<%!-- Progress bars for each file --%>
<%= for entry <- @uploads.photos.entries do %>
<!-- => Loops over @uploads.photos.entries, binding each element to entry -->
<div class="upload-entry">
<!-- => Div container with class="upload-entry" -->
<p><%= entry.client_name %></p>
<!-- => Paragraph element displaying dynamic content -->
<div class="progress-bar">
<!-- => Div container with class="progress-bar" -->
<div class="progress-fill" style={"width: #{entry.progress}%"}>
<!-- => Div container with class="progress-fill" -->
<%= entry.progress %>%
<!-- => Evaluates Elixir expression and outputs result as HTML -->
</div>
<!-- => Closes outer div container -->
</div>
<!-- => Closes outer div container -->
<%!-- Show file metadata --%>
<p class="meta">
<!-- => Paragraph element displaying dynamic content -->
Size: <%= format_bytes(entry.client_size) %> |
<!-- => Static text with interpolated Elixir expression -->
Type: <%= entry.client_type %>
<!-- => Static text with interpolated Elixir expression -->
</p>
<!-- => Closes paragraph element -->
<%!-- Entry-specific errors --%>
<%= for err <- upload_errors(@uploads.photos, entry) do %>
<!-- => Loops over upload_errors(@uploads.photos,, binding each element to err -->
<p class="error"><%= error_to_string(err) %></p>
<!-- => Paragraph element displaying dynamic content -->
<% end %>
<!-- => End of conditional/loop block -->
</div>
<!-- => Closes outer div container -->
<% end %>
<!-- => End of conditional/loop block -->
<button type="submit" disabled={length(@uploads.photos.entries) == 0}>
<!-- => Submit button — triggers phx-submit form event -->
Upload <%= length(@uploads.photos.entries) %> Photos
<!-- => Static text with interpolated Elixir expression -->
</button>
<!-- => Closes button element -->
</form>
<!-- => Closes form element — phx-submit/phx-change handlers deactivated -->
<h3>Uploaded Photos</h3>
<!-- => H3 heading element -->
<div class="photo-grid">
<!-- => Div container with class="photo-grid" -->
<%= for photo <- @uploaded_files do %>
<!-- => Loops over @uploaded_files, binding each element to photo -->
<div>
<!-- => Div container wrapping component content -->
<img src={photo.url} alt={photo.name} width="200" />
<!-- => img HTML element -->
<p><%= photo.name %></p>
<!-- => Paragraph element displaying dynamic content -->
</div>
<!-- => Closes outer div container -->
<% end %>
<!-- => End of conditional/loop block -->
</div>
<!-- => Closes outer div container -->
</div>
<!-- => Closes outer div container -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
defp format_bytes(bytes) when bytes < 1024, do: "#{bytes} B"
# => Defines format_bytes function
defp format_bytes(bytes) when bytes < 1_048_576, do: "#{div(bytes, 1024)} KB"
# => Defines format_bytes function
defp format_bytes(bytes), do: "#{div(bytes, 1_048_576)} MB"
# => Defines format_bytes function
defp error_to_string(:too_large), do: "File too large (max 5MB)"
# => Defines error_to_string function
defp error_to_string(:not_accepted), do: "Invalid file type"
# => Defines error_to_string function
end
# => Closes enclosing function/module/block definitionKey Takeaway: Access entry.progress, entry.client_size, and entry.client_type to display real-time upload progress and metadata.
Why It Matters: Upload progress tracking is a UX requirement for any file over a few kilobytes - without it, users don't know if the upload is working or stalled. LiveView's built-in entry.progress provides real-time percentage updates through the WebSocket connection without requiring polling or callbacks. In production applications handling large file uploads - video processing, bulk document imports, large data exports - progress indicators prevent users from canceling uploads prematurely or re-submitting thinking the first attempt failed. Progress tracking also enables throttling decisions and timeout handling.
Example 58: File Validation
Implement custom file validation beyond built-in rules.
Code:
defmodule MyAppWeb.ValidatedUploadLive do
# => Defines module MyAppWeb.ValidatedUploadLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
def mount(_params, _session, socket) do
# => Called on LiveView initialization
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> assign(:uploaded_files, [])
# => Sets assigns.uploaded_files
|> allow_upload(:images,
# => Configures file upload parameters
accept: ~w(.jpg .jpeg .png),
# => Allowed file extensions: .jpg .jpeg .png
max_entries: 3,
# => Maximum 3 file(s) allowed per upload
max_file_size: 2_000_000
# => Maximum file size: 0MB (2 bytes)
)
# => Closes multi-line function call
{:ok, socket}
# => Returns success tuple to LiveView runtime
end
# => Closes enclosing function/module/block definition
def handle_event("validate", _params, socket) do
# => Handles "validate" event from client
# Additional custom validation beyond allow_upload config
socket =
# => socket updated via pipeline below
Enum.reduce(socket.assigns.uploads.images.entries, socket, fn entry, acc_socket ->
# => Accumulates result by applying function to each element
# Check image dimensions (requires reading file)
with {:ok, dimensions} <- get_image_dimensions(entry) do
# => Chains operations, stops on first mismatch
if dimensions.width > 4000 or dimensions.height > 4000 do
# => Branches on condition: executes inner block when dimensions.width > 4000 or dimensions.he is truthy
# Cancel upload with custom error
cancel_upload(acc_socket, :images, entry.ref)
# => Cancels upload entry, removes from queue
else
# => Else branch executes when condition was false
acc_socket
# => acc_socket piped into following operations
end
# => Closes enclosing function/module/block definition
else
# => Else branch executes when condition was false
_ -> acc_socket
# => Catch-all pattern: matches any value not handled above
end
# => Closes enclosing function/module/block definition
end)
# => Closes anonymous function; returns result to calling function
{:noreply, socket}
# => Returns updated socket, triggers re-render
end
# => Closes enclosing function/module/block definition
def handle_event("upload", _params, socket) do
# => Handles "upload" event from client
uploaded_files =
# => Binds result to variable
consume_uploaded_entries(socket, :images, fn %{path: path}, entry ->
# => Processes uploaded files, returns results list
# Validate one more time before saving
case validate_image_content(path) do
# => Pattern matches on result value
:ok ->
# => Matches this pattern — executes right-hand side
dest = Path.join("priv/static/uploads/images", entry.client_name)
# => dest bound to result of Path.join("priv/static/uploads/images", entry.clie
File.cp!(path, dest)
# => Copies uploaded temp file to permanent destination path
{:ok, "/uploads/images/#{entry.client_name}"}
# => Pattern: successful result — result bound to returned value
{:error, reason} ->
# => Pattern: error result — reason bound to error reason
{:postpone, reason} # => Postpone this entry, keep others
end
# => Closes enclosing function/module/block definition
end)
# => Closes anonymous function; returns result to calling function
socket = assign(socket, :uploaded_files, uploaded_files)
# => socket.assigns.uploaded_files = uploaded_files
{:noreply, socket}
# => Returns updated socket, triggers re-render
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<div>
<!-- => Div container wrapping component content -->
<h2>Image Upload with Validation</h2>
<!-- => H2 heading element -->
<form phx-change="validate" phx-submit="upload">
<!-- => form HTML element -->
<.live_file_input upload={@uploads.images} />
<!-- => element HTML element -->
<ul class="requirements">
<!-- => List container for rendered items -->
<li>JPG or PNG format</li>
<!-- => List item rendered for each element -->
<li>Maximum 2MB per file</li>
<!-- => List item rendered for each element -->
<li>Maximum 3 files</li>
<!-- => List item rendered for each element -->
<li>Maximum 4000x4000 pixels</li>
<!-- => List item rendered for each element -->
</ul>
<!-- => Closes unordered list container -->
<%= for entry <- @uploads.images.entries do %>
<!-- => Loops over @uploads.images.entries, binding each element to entry -->
<div>
<!-- => Div container wrapping component content -->
<%= entry.client_name %>
<!-- => Evaluates Elixir expression and outputs result as HTML -->
<%= for err <- upload_errors(@uploads.images, entry) do %>
<!-- => Loops over upload_errors(@uploads.images,, binding each element to err -->
<p class="error"><%= error_to_string(err) %></p>
<!-- => Paragraph element displaying dynamic content -->
<% end %>
<!-- => End of conditional/loop block -->
</div>
<!-- => Closes outer div container -->
<% end %>
<!-- => End of conditional/loop block -->
<button type="submit">Upload Images</button>
<!-- => Submit button — triggers phx-submit form event -->
</form>
<!-- => Closes form element — phx-submit/phx-change handlers deactivated -->
<h3>Uploaded Images</h3>
<!-- => H3 heading element -->
<%= for image <- @uploaded_files do %>
<!-- => Loops over @uploaded_files, binding each element to image -->
<img src={image} alt="Uploaded" width="300" />
<!-- => img HTML element -->
<% end %>
<!-- => End of conditional/loop block -->
</div>
<!-- => Closes outer div container -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
defp get_image_dimensions(_entry) do
# => Defines get_image_dimensions function
# Simulate dimension check (would use actual image library)
{:ok, %{width: 2000, height: 1500}}
# => Pattern: successful result — result bound to returned value
end
# => Closes enclosing function/module/block definition
defp validate_image_content(_path) do
# => Defines validate_image_content function
# Validate file content (check magic bytes, scan for malicious content)
:ok
# => Returns :ok atom — signals successful completion
end
# => Closes enclosing function/module/block definition
defp error_to_string(:too_large), do: "File too large"
# => Defines error_to_string function
defp error_to_string(:not_accepted), do: "Invalid file type"
# => Defines error_to_string function
defp error_to_string(:external_client_failure), do: "Upload failed"
# => Defines error_to_string function
end
# => Closes enclosing function/module/block definitionKey Takeaway: Use cancel_upload/3 to reject uploads with custom validation errors, and {:postpone, reason} in consume to skip problematic files.
Why It Matters: Client-side upload validation prevents invalid files from consuming server bandwidth and storage. Checking file type and size before upload begins reduces server load and provides faster user feedback. The consume_uploaded_entries pattern also enables server-side validation that runs only after successful upload, for checks that require reading file content (virus scanning, image dimension validation, document parsing). In production applications, combining client-side and server-side validation creates defense in depth - client validation for UX, server validation for security. Never rely solely on client validation for security-sensitive checks.
Example 59: Consume Uploaded Entries
Process uploaded files with consume_uploaded_entries/3 callback.
File consumption pipeline:
%% Upload consumption pipeline
graph TD
A[Upload Complete] --> B[consume_uploaded_entries]
B --> C[For each entry]
C --> D[path: tmp file location]
D --> E[Process: copy/store/parse]
E --> F{Success?}
F -->|Yes| G[Return processed data]
F -->|No| H[postpone: retry later]
G --> I[Accumulate results]
style A fill:#0173B2,color:#fff
style B fill:#DE8F05,color:#fff
style D fill:#029E73,color:#fff
style F fill:#CC78BC,color:#fff
style G fill:#CA9161,color:#fff
Code:
defmodule MyAppWeb.BatchUploadLive do
# => Defines module MyAppWeb.BatchUploadLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
def mount(_params, _session, socket) do
# => Called on LiveView initialization
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> assign(:results, [])
# => Sets assigns.results
|> allow_upload(:csv_files,
# => Configures file upload parameters
accept: ~w(.csv),
# => Allowed file extensions: .csv
max_entries: 10,
# => Maximum 10 file(s) allowed per upload
max_file_size: 50_000_000
# => Maximum file size: 0MB (50 bytes)
)
# => Closes multi-line function call
{:ok, socket}
# => Returns success tuple to LiveView runtime
end
# => Closes enclosing function/module/block definition
def handle_event("validate", _params, socket) do
# => Handles "validate" event from client
{:noreply, socket}
# => Returns updated socket, triggers re-render
end
# => Closes enclosing function/module/block definition
def handle_event("upload", _params, socket) do
# => Handles "upload" event from client
# Process each uploaded file
results =
# => Binds result to variable
consume_uploaded_entries(socket, :csv_files, fn %{path: path}, entry ->
# => Processes uploaded files, returns results list
# path: temporary file path (deleted after consume completes)
# entry: metadata (client_name, content_type, client_size, etc.)
case process_csv(path, entry.client_name) do
# => Pattern matches on result value
{:ok, row_count} ->
# => Matches this pattern — executes right-hand side
# Success: return processed data
{:ok, %{
# => Pattern: successful result — result bound to returned value
name: entry.client_name,
# => Sets name: field to entry.client_name
status: :success,
# => Sets status: field to :success
rows: row_count,
# => Sets rows: field to row_count
size: entry.client_size
# => Sets size: field to entry.client_size
}}
# => Closes nested struct/map construction
{:error, reason} ->
# => Pattern: error result — reason bound to error reason
# Error: return error info
{:ok, %{
# => Pattern: successful result — result bound to returned value
name: entry.client_name,
# => Sets name: field to entry.client_name
status: :error,
# => Sets status: field to :error
error: reason
# => Sets error field to reason in result map
}}
# => Closes nested struct/map construction
end
# => Closes enclosing function/module/block definition
end)
# => results: list of return values from callback
socket = assign(socket, :results, results) # => Display results
# => socket.assigns.results = results
{:noreply, socket}
# => Returns updated socket, triggers re-render
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<div>
<!-- => Div container wrapping component content -->
<h2>CSV Batch Upload</h2>
<!-- => H2 heading element -->
<form phx-change="validate" phx-submit="upload">
<!-- => form HTML element -->
<.live_file_input upload={@uploads.csv_files} />
<!-- => element HTML element -->
<%= for entry <- @uploads.csv_files.entries do %>
<!-- => Loops over @uploads.csv_files.entries, binding each element to entry -->
<div>
<!-- => Div container wrapping component content -->
<%= entry.client_name %> - <%= format_bytes(entry.client_size) %>
<!-- => Evaluates Elixir expression and outputs result as HTML -->
</div>
<!-- => Closes outer div container -->
<% end %>
<!-- => End of conditional/loop block -->
<button type="submit">Process CSV Files</button>
<!-- => Submit button — triggers phx-submit form event -->
</form>
<!-- => Closes form element — phx-submit/phx-change handlers deactivated -->
<h3>Processing Results</h3>
<!-- => H3 heading element -->
<table>
<!-- => table HTML element -->
<thead>
<!-- => thead HTML element -->
<tr>
<!-- => tr HTML element -->
<th>File</th>
<!-- => th HTML element -->
<th>Status</th>
<!-- => th HTML element -->
<th>Rows</th>
<!-- => th HTML element -->
<th>Size</th>
<!-- => th HTML element -->
</tr>
<!-- => Closes table row element -->
</thead>
<!-- => Closes table header section -->
<tbody>
<!-- => tbody HTML element -->
<%= for result <- @results do %>
<!-- => Loops over @results, binding each element to result -->
<tr>
<!-- => tr HTML element -->
<td><%= result.name %></td>
<!-- => td HTML element -->
<td class={result.status}><%= result.status %></td>
<!-- => td HTML element -->
<td><%= result[:rows] || "-" %></td>
<!-- => td HTML element -->
<td><%= format_bytes(result[:size] || 0) %></td>
<!-- => td HTML element -->
</tr>
<!-- => Closes table row element -->
<% end %>
<!-- => End of conditional/loop block -->
</tbody>
<!-- => Closes table body section -->
</table>
<!-- => Closes table element -->
</div>
<!-- => Closes outer div container -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
defp process_csv(path, name) do
# => Defines process_csv function
# Simulate CSV processing
IO.puts("Processing #{name}...")
# => Output: prints string to console
row_count = :rand.uniform(1000)
# => row_count bound to result of :rand.uniform(1000)
{:ok, row_count}
# => Pattern: successful result — row_count bound to returned value
end
# => Closes enclosing function/module/block definition
defp format_bytes(bytes) when bytes < 1024, do: "#{bytes} B"
# => Defines format_bytes function
defp format_bytes(bytes) when bytes < 1_048_576, do: "#{div(bytes, 1024)} KB"
# => Defines format_bytes function
defp format_bytes(bytes), do: "#{div(bytes, 1_048_576)} MB"
# => Defines format_bytes function
end
# => Closes enclosing function/module/block definitionKey Takeaway: consume_uploaded_entries/3 receives temporary file path and entry metadata, returning your processed data which accumulates in a list.
Why It Matters: The consume_uploaded_entries pattern is where uploaded files become application data. This is where you call external services (cloud storage APIs, image processors, document parsers), create database records, and handle errors. The two-phase approach - upload to temp storage, then consume - enables atomic processing: if your storage API call fails, the temp file is retained for retry. In production applications, this is where you integrate with S3, process images, extract metadata, and link files to database records. Understanding consume is essential for any file upload feature beyond basic file saving.
Example 60: Multiple Upload Configurations
Configure multiple independent upload inputs in a single LiveView.
Code:
defmodule MyAppWeb.ProfileUploadLive do
# => Defines module MyAppWeb.ProfileUploadLive
use MyAppWeb, :live_view
# => Imports LiveView macros and callbacks
def mount(_params, _session, socket) do
# => Called on LiveView initialization
socket =
# => socket updated via pipeline below
socket
# => Starting socket as base for pipeline operations
|> assign(:avatar_url, nil)
# => Sets assigns.avatar_url
|> assign(:document_urls, [])
# => Sets assigns.document_urls
|> allow_upload(:avatar, # => First upload config
# => Configures avatar upload: sets file type, size, count limits
accept: ~w(.jpg .jpeg .png),
# => Allowed file extensions: .jpg .jpeg .png
max_entries: 1,
# => Maximum 1 file(s) allowed per upload
max_file_size: 1_000_000 # => 1MB for avatar
)
# => Closes multi-line function call
|> allow_upload(:documents, # => Separate :documents config from :avatar
# => Configures documents upload: sets file type, size, count limits
accept: ~w(.pdf .doc .docx),
# => Allowed file extensions: .pdf .doc .docx
max_entries: 5,
# => Maximum 5 file(s) allowed per upload
max_file_size: 10_000_000 # => 10MB for documents
)
# => Two independent upload configurations
{:ok, socket}
# => Returns success tuple to LiveView runtime
end
# => Closes enclosing function/module/block definition
def handle_event("validate", _params, socket) do
# => Handles "validate" event from client
{:noreply, socket} # => Both uploads validated independently
end
# => Closes enclosing function/module/block definition
def handle_event("save_avatar", _params, socket) do
# => Handles "save_avatar" event from client
# Process only avatar upload
[avatar_url] =
# => Destructures list: binds first element to avatar_url
consume_uploaded_entries(socket, :avatar, fn %{path: path}, entry ->
# => Processes uploaded files, returns results list
dest = Path.join("priv/static/uploads/avatars", entry.client_name)
# => dest bound to result of Path.join("priv/static/uploads/avatars", entry.cli
File.cp!(path, dest)
# => Copies uploaded temp file to permanent destination path
{:ok, "/uploads/avatars/#{entry.client_name}"}
# => Pattern: successful result — result bound to returned value
end)
# => Closes anonymous function; returns result to calling function
socket = assign(socket, :avatar_url, avatar_url) # => Update avatar
# => socket.assigns.avatar_url = avatar_url
{:noreply, socket}
# => Returns updated socket, triggers re-render
end
# => Closes enclosing function/module/block definition
def handle_event("save_documents", _params, socket) do
# => Handles "save_documents" event from client
# Process only documents upload
document_urls =
# => Binds result to variable
consume_uploaded_entries(socket, :documents, fn %{path: path}, entry ->
# => Processes uploaded files, returns results list
dest = Path.join("priv/static/uploads/documents", entry.client_name)
# => dest bound to result of Path.join("priv/static/uploads/documents", entry.c
File.cp!(path, dest)
# => Copies uploaded temp file to permanent destination path
{:ok, "/uploads/documents/#{entry.client_name}"}
# => Pattern: successful result — result bound to returned value
end)
# => Closes anonymous function; returns result to calling function
socket = update(socket, :document_urls, fn urls -> urls ++ document_urls end)
# => Updates assigns.document_urls using current value
{:noreply, socket}
# => Returns updated socket, triggers re-render
end
# => Closes enclosing function/module/block definition
def render(assigns) do
# => Generates LiveView HTML template
~H"""
<!-- => Opens HEEx template — HTML+Elixir embedded template language -->
<div>
<!-- => Div container wrapping component content -->
<h2>Profile Setup</h2>
<!-- => H2 heading element -->
<%!-- Avatar upload section --%>
<div class="section">
<!-- => Div container with class="section" -->
<h3>Profile Avatar</h3>
<!-- => H3 heading element -->
<form phx-change="validate" phx-submit="save_avatar">
<!-- => form HTML element -->
<.live_file_input upload={@uploads.avatar} />
<!-- => element HTML element -->
<%= for entry <- @uploads.avatar.entries do %>
<!-- => Loops over @uploads.avatar.entries, binding each element to entry -->
<div><%= entry.client_name %> - <%= entry.progress %>%</div>
<!-- => Div container wrapping component content -->
<% end %>
<!-- => End of conditional/loop block -->
<button type="submit">Upload Avatar</button>
<!-- => Submit button — triggers phx-submit form event -->
</form>
<!-- => Closes form element — phx-submit/phx-change handlers deactivated -->
<%= if @avatar_url do %>
<!-- => Renders inner content only when @avatar_url is truthy -->
<img src={@avatar_url} alt="Avatar" width="150" />
<!-- => img HTML element -->
<% end %>
<!-- => End of conditional/loop block -->
</div>
<!-- => Closes outer div container -->
<%!-- Documents upload section --%>
<div class="section">
<!-- => Div container with class="section" -->
<h3>Supporting Documents</h3>
<!-- => H3 heading element -->
<form phx-change="validate" phx-submit="save_documents">
<!-- => form HTML element -->
<.live_file_input upload={@uploads.documents} />
<!-- => element HTML element -->
<%= for entry <- @uploads.documents.entries do %>
<!-- => Loops over @uploads.documents.entries, binding each element to entry -->
<div><%= entry.client_name %> - <%= entry.progress %>%</div>
<!-- => Div container wrapping component content -->
<% end %>
<!-- => End of conditional/loop block -->
<button type="submit">Upload Documents</button>
<!-- => Submit button — triggers phx-submit form event -->
</form>
<!-- => Closes form element — phx-submit/phx-change handlers deactivated -->
<h4>Uploaded Documents</h4>
<!-- => H4 heading element -->
<ul>
<!-- => List container for rendered items -->
<%= for doc <- @document_urls do %>
<!-- => Loops over @document_urls, binding each element to doc -->
<li><a href={doc}><%= Path.basename(doc) %></a></li>
<!-- => List item rendered for each element -->
<% end %>
<!-- => End of conditional/loop block -->
</ul>
<!-- => Closes unordered list container -->
</div>
<!-- => Closes outer div container -->
</div>
<!-- => Closes outer div container -->
"""
# => Closes HEEx template string
end
# => Closes enclosing function/module/block definition
end
# => Closes enclosing function/module/block definitionKey Takeaway: Call allow_upload/3 multiple times with different names to configure independent upload inputs with separate validation rules and processing.
Why It Matters: Different upload contexts have different requirements - a profile picture has different constraints than a document attachment or a bulk data import. Configuring multiple independent upload inputs with allow_upload allows a single LiveView to handle these distinct upload flows with appropriate validation for each. In production forms with multiple file attachment types - reports with an attached document and a cover image, for example - independent upload configurations provide clean separation with targeted error messages per upload type. Each upload input can have different accept types, size limits, and processing logic.
Next Steps
Continue to advanced examples covering LiveComponents, JavaScript interop, testing, and production patterns:
Or review fundamentals:
Last updated January 31, 2026