patch_element/3 renders a Phoenix function component against the current connection assigns and sends the resulting HTML as a Datastar datastar-patch-elements event.

The generated search controller in priv/templates/search_controller.ex.eex uses this pattern:

  1. mount/2 puts initial state on the connection.
  2. render/1 renders the first page and emits initial browser signals with init_signals/1.
  3. Datastar sends an event with the current browser signals.
  4. handle_event/3 updates assigns or signals on the connection.
  5. patch_element/3 re-renders only the component that changed.

Component Example

Write function components the same way you would in a Phoenix view module:

attr :results, :list, default: []

def item_list(assigns) do
  ~H"""
  <ul id="item-list" class="grid gap-2" data-show={query_results("> 0")}>
    <.item :for={item <- @results} item={item} />
  </ul>
  """
end

attr :item, :string, required: true

def item(assigns) do
  ~H"""
  <li class="border p-4" data-show={starts_with?("'#{@item}'")}>
    {@item}
  </li>
  """
end

When the component is rendered from the initial page, attributes come from the parent template:

@impl StarView
def render(assigns) do
  ~H"""
  <div data-signals={init_signals(@conn)}>
    <.item_list results={@results} />
  </div>
  """
end

When the component is rendered by patch_element/3, it receives conn.assigns. Set every value the component needs before patching it:

@impl StarView
def handle_event("search", %{"query" => query}, conn) do
  conn
  |> signal(:results, get_items(query))
  |> patch_element(&item_list/1)
end

In this example, signal(:results, ...) writes conn.assigns.results for the component and patches $results for the browser.

If the component needs values that are not already in conn.assigns, pass a small rendering function:

def handle_event("add_item", %{"name" => name}, conn) do
  patch_element(conn, fn assigns ->
    item(Map.put(assigns, :item, name))
  end, to: "item-list", mode: :append)
end

Pass raw HTML when you already have the rendered content:

patch_element(conn, "<li>Saved</li>", to: "item-list", mode: :append)

Full Flow

The active-search template uses signals for state the browser should know about:

@impl StarView
def mount(conn, _params) do
  conn
  |> signal(:query, "")
  |> signal(:results, @items)
end

During the initial render, those values are available as assigns and as Datastar signals:

@impl StarView
def render(assigns) do
  ~H"""
  <div class="max-w-xl mx-auto p-6" data-signals={init_signals(@conn)}>
    <.search_form />
    <.item_list results={@results} />
    <.no_results query={@query} />
  </div>
  """
end

The input binds directly to the browser signal:

def search_form(assigns) do
  ~H"""
  <div class="mb-4 flex gap-2">
    <input
      type="text"
      class="input grow"
      placeholder="Search frameworks..."
      data-bind:query
      data-on:input__debounce.200ms={post("search")}
    />
    <button class="btn" data-on:click={post("reset")}>
      Reset
    </button>
  </div>
  """
end

When the user types, Datastar posts the current signal map. StarView starts the SSE response, calls the controller, and the controller returns patched state and patched HTML:

@impl StarView
def handle_event("search", %{"query" => query} = signals, conn) do
  conn
  |> signal(:results, get_items(query))
  |> maybe_patch_list(signals)
end

defp maybe_patch_list(%{assigns: %{results: results}} = conn, %{"results" => results}) do
  conn
end

defp maybe_patch_list(conn, _signals) do
  patch_element(conn, &item_list/1)
end

The signals argument is the browser's JSON state at request time, so its keys are strings. conn.assigns is the server render state after your pipeline runs, so its keys are atoms. Comparing both lets you skip an element patch when the server result list did not actually change.

Assigns vs Signals

Use assign/3 when only the server-rendered component needs the value:

def handle_event("show_profile", %{"id" => id}, conn) do
  profile = Accounts.get_profile!(id)

  conn
  |> assign(:profile, profile)
  |> patch_element(&profile_card/1, to: "profile")
end

The browser receives only the HTML patch. It does not receive profile as JSON. This is the right choice for server-only data, large structs, values that cannot be encoded cleanly as JSON, and values the browser should not own.

Use signal/3 when the browser should react to the value or send it back on the next event:

def handle_event("select_tab", %{"tab" => tab}, conn) do
  conn
  |> signal(:tab, tab)
  |> assign(:items, Items.for_tab(tab))
  |> patch_element(&tab_panel/1)
end

The component can read @tab because signal/3 also assigns the value, and the browser can read $tab in attributes such as data-show, data-text, or data-bind.

The rule of thumb is:

StateUseWhy
Render-only server stateassign/3Function components can read it; the browser does not receive it.
Browser-visible JSON statesignal/3Components can read it and Datastar can react to it.
Event input from the browsersignals argumentIt is the submitted client state before the handler's updates.

Targeting

If the rendered element has a stable id, you can often let Datastar target it from the patched HTML:

patch_element(conn, &item_list/1)

Use :to when you want to target a DOM id explicitly. StarView turns the id into a CSS selector:

patch_element(conn, &item_list/1, to: "item-list", mode: :replace)

Use Datastar element options directly when you need more control:

patch_element(conn, &item_list/1, selector: "#item-list", mode: :append)

The default element patch mode is :outer. Other supported modes include :inner, :replace, :prepend, :append, :before, :after, and :remove.

Change Checks

StarView does not maintain LiveView-style change tracking. If a handler can skip an HTML patch, keep that decision explicit:

defp maybe_patch_list(%{assigns: %{results: results}} = conn, %{"results" => results}) do
  conn
end

defp maybe_patch_list(conn, _signals) do
  patch_element(conn, &item_list/1)
end

Signal patches and element patches are independent. It is valid to update a signal without patching HTML when Datastar can handle the UI change locally, and it is valid to patch HTML from assigns without exposing those assigns as signals.