Skip to main content

guides/core/patch_element.md

# `patch_element/3`

`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 through `Layout.app/1`, which 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:

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

```elixir
@impl StarView
def render(assigns) do
  ~H"""
  <Layout.app conn={@conn}>
    <.item_list results={@results} />
  </Layout.app>
  """
end
```

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

```elixir
@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:

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

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

```elixir
@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:

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

The input binds directly to the browser signal:

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

```elixir
@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:

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

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

| State | Use | Why |
| --- | --- | --- |
| Render-only server state | `assign/3` | Function components can read it; the browser does not receive it. |
| Browser-visible JSON state | `signal/3` | Components can read it and Datastar can react to it. |
| Event input from the browser | `signals` argument | It 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:

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

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

Use Datastar element options directly when you need more control:

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

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