Components
April 22, 2022 10 minutes • 2013 words
Table of contents
- @impl true
-
Stateful by adding
id
and pointing to the Component with@myself
- Card Component with Form
- Listing of cards
- LiveView as the Source: self()
- LiveComponent as the Source
- LiveComponent blocks
- Stateless components
-
Stateful Components via
:id
- Managing state
- LiveComponent blocks
- Live patches and live redirects
- Cost of stateful components
- Limitations
- Form Bindings
@impl true
Tells LiveView to implement the callback
@impl true
def render(assigns) do
~L"""
whatever
"""
Stateful by adding id
and pointing to the Component with @myself
Add id
to the live_component to make it ‘stateful’
<%= live_component @socket, MessageComponent, id: 1 %>
Add @myself
to the stateful form to make the Component handle the event
<form phx-submit="send-message" phx-target="<%= @myself %>" %>
Card Component with Form
defmodule CardComponent do
def render(assigns) do
~H"\""
<form phx-submit="..." phx-target={@myself}>
<input name="title"><%= @card.title %></input>
...
</form>
"\""
end
Listing of cards
<%= for card <- @cards do %>
<%= live_component CardComponent, card: card, id: card.id, board_id: @id %>
<% end %>
Form submission triggers CardComponent.handle_event/3
which must update the card.
LiveView as the Source: self()
The component and the view run in the same process. So, sending an internal message from the LiveComponent to the parent LiveView is done by sending a message to self()
:
send self(), {:message_name, %{content_of_message}}
defmodule CardComponent do
...
def handle_event("update_title", %{"title" => title}, socket) do
send self(), {:updated_card, %{socket.assigns.card | title: title}}
{:noreply, socket}
LiveView then receives this event using c:Phoenix.LiveView.handle_info/2
:
defmodule BoardView do
...
def handle_info({:updated_card, card}, socket) do
# update the list of cards in the socket
{:noreply, updated_socket}
The LiveView will send the updated card to the component.
Alternatively, the could be updated via broadcast by using Phoenix.PubSub
to all users subscribed to it.
defmodule CardComponent do
...
def handle_event("update_title", %{"title" => title}, socket) do
message = {:updated_card, %{socket.assigns.card | title: title}}
Phoenix.PubSub.broadcast(MyApp.PubSub, board_topic(socket), message)
{:noreply, socket}
end
defp board_topic(socket) do
"board:" <> socket.assigns.board_id
LiveComponent as the Source
In this case, LiveView must only fetch the card ids, then render each component only by passing an ID:
<%= for card_id <- @card_ids do %>
<%= live_component CardComponent, id: card_id, board_id: @id %>
<% end %>
Each CardComponent will load its own card making expensive N queries, where N is the number of cards. So we use the c:preload/1
callback to make it efficient.
Once the card components are started, they can each manage their own card, without concern for the parent LiveView.
Components do not have a c:Phoenix.LiveView.handle_info/2
callback. Therefore, if you want to track distributed changes on a card, you must have LiveView receive those events and redirect them to the appropriate card.
For example, if card updates are sent to the “board:ID” topic, and that the board LiveView is subscribed to the said topic, one could do:
def handle_info({:updated_card, card}, socket) do
send_update CardComponent, id: card.id, board_id: socket.assigns.id
{:noreply, socket}
With Phoenix.LiveView.send_update/3
, the CardComponent
given by id
will be invoked, triggering both preload and update callbacks, which will load the most up to date data from the database.
LiveComponent blocks
When live_component/3
(Phoenix.LiveView.Helpers.live_component/3)
is invoked, it is also possible to pass a do/end
block:
<%= live_component GridComponent, entries: @entries do %>
<% entry -> %>New entry: <%= entry %>
<% end %>
The do/end
will be available in an assign named @inner_block
.
You can render its contents by calling render_block
with the assign itself and a keyword list of assigns to inject into the rendered
content. For example, the grid component above could be implemented as:
defmodule GridComponent do
use Phoenix.LiveComponent
def render(assigns) do
~H"\""
<div class="grid">
<%= for entry <- @entries do %>
<div class="column">
<%= render_block(@inner_block, entry) %>
</div>
<% end %>
</div>
"\""
end
end
Where the entry
variable was injected into the do/end
block.
Note the @inner_block
assign is also passed to c:update/2
along all other assigns. So if you have a custom update/2
implementation, make sure to assign it to the socket like so:
def update(%{inner_block: inner_block}, socket) do
{:ok, assign(socket, inner_block: inner_block)}
end
Components run inside the LiveView process, but may have their own state and event handling. They are either stateless or stateful.
Setting
Phoenix.LiveComponent
Calling
Phoenix.LiveView.Helpers.live_component/3` in a parent LiveView
Simplest Stateless Component: render/1
defmodule HeroComponent do
use MyAppWeb, :live_component
def render(assigns) do
~L\"""
<div><%= @content %></div>
\"""
end
end
Passing Data to Component from Parent LiveView
<%= live_component HeroComponent, content: @content %>
Stateless components
live_component/3
calls three things:
mount(socket) -> update(assigns, socket) -> render(assigns)
mount/1
sets the initial stateupdate/2
sets all of the assigns given tolive_component/3
. Ifc:update/2
is not defined, all assigns are simply merged into the socketrender/1
renders all assigns
A stateless component is always mounted, updated, and rendered whenever the parent template changes.
Stateful Components via:id
Components become stateful by passing an :id
assign.
<%= live_component HeroComponent, id: :hero, content: @content %>
They are identified by the component module and their ID. Therefore, two different component modules with the same ID are different components. This means we can often tie the component ID to some application based ID:
<%= live_component UserComponent, id: @user.id, user: @user %>
The given :id
is not necessarily used as the DOM ID. If you want to set a DOM ID, it is your responsibility to set it when rendering:
defmodule UserComponent do
use Phoenix.LiveComponent
def render(assigns) do
~L\"""
<div id="user-<%= @id %>" class="user"><%= @user.name %></div>
\"""
end
end
Stateful components should only a single root element in the HTML template.
In stateful components, c:mount/1
is called only once when it is first rendered. For each rendering, the optional c:preload/1
and c:update/2
callbacks are called before c:render/1
.
So on first render, the following callbacks will be invoked:
preload(list_of_assigns) -> mount(socket) -> update(assigns, socket) -> render(assigns)
On subsequent renders, these callbacks will be invoked:
preload(list_of_assigns) -> update(assigns, socket) -> render(assigns)
Component Events
For a client event to reach a component, the tag must have phx-target
. Use @myself
to send it to the component assign.
<a href="#" phx-click="say_hello" phx-target="<%= @myself %>">
Say hello!
</a>
@myself
is not set for stateless components. To target another component, pass an ID or a class selector to any element inside the targeted component. Only the diff of the component is sent to the client, making them extremely efficient.
<a href="#" phx-click="say_hello" phx-target="#user-13">
Say hello!
</a>
Any valid query selector for phx-target
is supported, provided that the matched nodes are children of a LiveView or LiveComponent, for example to send the close
event to multiple components:
<a href="#" phx-click="close" phx-target="#modal, #sidebar">
Dismiss
</a>
Managing state
The parent LiveView and its LiveComponent should not work on 2 different copies of the state. Only one is the truth.
In this scenario, each LiveView Card has a form to update the card title directly:
defmodule CardComponent do
use Phoenix.LiveComponent
def render(assigns) do
~L\"""
<form phx-submit="..." phx-target="<%= @myself %>">
<input name="title"><%= @card.title %></input>
...
</form>
\"""
end
...
end
LiveView as the source of truth
The board LiveView will fetch all the cards in a board, calling live_component/3
for each card, passing the card struct as argument to CardComponent
:
<%= for card <- @cards do %>
<%= live_component CardComponent, card: card, id: card.id, board_id: @id %>
<% end %>
When the user submits the form, CardComponent.handle_event/3
the Component sends an internal message to its parent Liveview via self()
:
defmodule CardComponent do
...
def handle_event("update_title", %{"title" => title}, socket) do
send self(), {:updated_card, %{socket.assigns.card | title: title}}
{:noreply, socket}
end
end
The LiveView receives this event using Phoenix.LiveView.handle_info/2
:
defmodule BoardView do
...
def handle_info({:updated_card, card}, socket) do
# update the list of cards in the socket
{:noreply, updated_socket}
end
end
The parent LiveView will be re-rendered, sending the updated card to the Component, or by broadcasting via Phoenix.PubSub
. This needs the LiveView to subscribe to the board:<ID>
topic
defmodule CardComponent do
...
def handle_event("update_title", %{"title" => title}, socket) do
message = {:updated_card, %{socket.assigns.card | title: title}}
Phoenix.PubSub.broadcast(MyApp.PubSub, board_topic(socket), message)
{:noreply, socket}
end
defp board_topic(socket) do
"board:" <> socket.assigns.board_id
end
end
LiveComponent as the source of truth
The board LiveView no longer fetches the card structs from the database. Instead, it only fetches the card ids, then render each component by passing an ID:
<%= for card_id <- @card_ids do %>
<%= live_component CardComponent, id: card_id, board_id: @id %>
<% end %>
Each CardComponent will load its own card. You should use preload/1
To broadcast changes on a card, the parent LiveView must receive those events and redirect them to the appropriate card.
def handle_info({:updated_card, card}, socket) do
send_update CardComponent, id: card.id, board_id: socket.assigns.id
{:noreply, socket}
end
Phoenix.LiveView.send_update/3
invokes the CardComponent
given by id
. This triggers preload and update callbacks.
LiveComponent blocks
do/end
block is possible with live_component/3
<%= live_component GridComponent, entries: @entries do %>
New entry: <%= @entry %>
<% end %>
The do/end
will be available in an assign named @inner_block
. You can render its contents by calling render_block
with the assign itself and a keyword list of assigns to inject into the rendered content. For example, the grid component above could be implemented as:
defmodule GridComponent do
use Phoenix.LiveComponent
def render(assigns) do
~L\"""
<div class="grid">
<%= for entry <- @entries do %>
<div class="column">
<%= render_block(@inner_block, entry: entry) %>
</div>
<% end %>
</div>
\"""
end
end
Where the :entry
assign was injected into the do/end
block.
The @inner_block
assign is also passed to c:update/2
along all other assigns. So if you have a custom update/2
implementation, make sure to assign it to the socket like so:
def update(%{inner_block: inner_block}, socket) do
{:ok, assign(socket, inner_block: inner_block)}
end
The above approach is the preferred one when passing blocks to do/end
. However, if you are outside of a .leex template and you want to invoke a component passing a do/end
block, you will have to explicitly handle the assigns by giving it a ->
clause:
live_component GridComponent, entries: @entries do
new_assigns -> "New entry: " <> new_assigns[:entry]
end
Live patches and live redirects
A template rendered inside a component can use:
Phoenix.LiveView.Helpers.live_patch/2
: this is always handled by the parentLiveView
, as components do not providehandle_params
Phoenix.LiveView.Helpers.live_redirect/2
Cost of stateful components
Keep only the assigns necessary in each component.
Avoid passing all of LiveView’s assigns when rendering a component:
<%= live_component MyComponent, assigns %>
Instead pass only the keys that you need:
<%= live_component MyComponent, user: @user, org: @org %>
The view and the component will share the same copies of the @user
and @org
assigns.
Avoid using stateful components to provide abstract DOM components
A good LiveComponent encapsulates application concerns and not DOM functionality. If your page shows products, you can encapsulate those products in a component. This component may have many buttons and events inside.
Do not write a component that is simply encapsulating generic DOM components:
defmodule MyButton do
use Phoenix.LiveComponent
def render(assigns) do
~L\"""
<button class="css-framework-class" phx-click="click">
<%= @text %>
</button>
\"""
end
def handle_event("click", _, socket) do
_ = socket.assigns.on_click.()
{:noreply, socket}
end
end
Instead, create a function:
def my_button(text, click) do
assigns = %{text: text, click: click}
~L\"""
<button class="css-framework-class" phx-click="<%= @click %>">
<%= @text %>
</button>
\"""
end
Limitations
- Components require at least one HTML tag
Components must only contain HTML tags at their root. At least one HTML tag must be present. It is not possible to have components that render only text or text mixed with tags at the root.
- Components must always be change tracked
If you render a component inside form_for
the component ends up enclosed by the form markup, where LiveView
cannot track it.
<%= form_for @changeset, "#", fn f -> %>
<%= live_component SomeComponent, f: f %>
<% end %>
This causes an error:
** (ArgumentError) cannot convert component SomeComponent to HTML.
A component must always be returned directly as part of a LiveView template
Solve this without anonymous functions:
<%= f = form_for @changeset, "#" %>
<%= live_component SomeComponent, f: f %>
</form>
This issue can also happen with other helpers, such as content_tag
:
<%= content_tag :div do %>
<%= live_component SomeComponent, f: f %>
<% end %>
So do not use content_tag
. Instead, use LiveEEx to build the markup.
Form Bindings
These are in forms that intercept JS events.
Phoenix.LiveView.Socket
is a server-side struct that has all the data. It has an assigns
key for current data.
The template exposes the data of the Socket to the browser.
Data is set to the Socket with Phoenix.Component.assign/2
and Phoenix.Component.assign/3
The browser accesses the Socket data in 2 ways:
- With LiveView as
socket.assigns.name_of_assign_data
- With Heex as
@name_of_assign_data