Best practices for defining function components

With an eye to the future that is being laid out by Phoenix LiveView 0.16 and the introduction of HEEx templates, we’ve recently begun transitioning to using function components, rendered with the component/3 macro, instead of stateless LiveComponents.

Marlus Saraiva lays out a pretty good roadmap on the Dashbit blog, but we’re finding that when implementing these stateless components, we still have a lot of unanswered questions, which Marlus raises in his post: When defining a function component, how should we declare:

  • which assigns are public and can (or must) be passed to component/3?
  • what’s the type of each assign?
  • which assigns are required? Will they receive default values if I don’t initialize them?
  • which assigns represent the internal state of the component and shouldn’t be touched at all?

Without answers to these questions and armed only with the guidance that a function component must accept a map of assigns and return a ~L (or ~H) template, we’ve been … making it up as we go along with code like this:

defmodule MyApp.Components.Checkbox do
  use Phoenix.HTML
  import Phoenix.LiveView.Helpers
  
  @default_assigns %{
    __changed__: nil,
    class: nil,
    form: :checkbox,
    id: nil,
    label: nil
  }
  @default_attrs []
  
  def checkbox(assigns) do
    {assigns, attrs} = Map.split(assigns, Map.keys(@default_assigns))
    attrs = Keyword.merge(@default_attrs, Map.to_list(attrs))

    assigns
    |> Enum.into(@default_assigns)
    |> render(attrs)
  end

  defp render(assigns, attrs) do
    ~L"""
    <div class="flex flex-row items-center checkbox <%= @class %>">
      <%= checkbox @form, @id, attrs %>
      <%= label @form, @id, @label, class: "whitespace-nowrap" %>
    </div>
    """
  end
end

What the code is attempting to do is primarily account for optional assigns, and set their defaults if they’re not supplied by the caller. But in addition, it’s defining all of the assigns that the component consumes as arguments and splitting everything else out as attributes to be passed to the underlying HTML. So in the above example:

<%= component &Checkbox.checkbox/1,
    id: :click_me,
    label: "Click me",
    phx_click: "click",
    phx_target: "#something" %>

would be rendered as

<div class="flex flex-row items-center checkbox">
  <input id="checkbox_click_me" name="checkbox[click_me]" type="checkbox" phx_click="click" phx-target="#something">
  <label class="whitespace-nowrap" for="checkbox_click_me">Click me</label>
</div>

Repeating these assigns/attribute gymnastics in every component is obviously a bad idea, but right now there’s little guidance as to what the best practice is when defining these function components.

What’s the right way? Discuss :slight_smile:

6 Likes

This is a good question, especially since there aren’t really any examples of things like this in the docs that I am aware of.

Here is what I’m doing, which seems to work pretty well for me.

I add this macro to MyAppWeb

defmodule MyAppWeb do
  # existing code...

  def function_component do
    quote do
      use Phoenix.Component
      import GigAppWeb.Components.Helpers
    end
  end
end

Then I add the GigAppWeb.Components.Helpers module that follows:

defmodule MyAppWeb.Components.Helpers do
  def assign_defaults(assigns, defaults) do
    Map.merge(defaults, assigns)
  end

  def assign_defaults(assigns, defaults, attrs) do
    attrs =
      attrs
      |> Map.merge(assigns)
      |> Map.drop(Map.keys(defaults) ++ [:__changed__])
      |> Map.to_list()

    assigns
    |> assign_defaults(defaults)
    |> Map.put(:attrs, attrs)
  end
end

And I use it like this:

defmodule MyAppWeb.Components do
  use MyAppWeb, :function_component

  @defaults %{name: "Bryan"}
  @attrs %{class: "yellow"}

  def greeting(assigns) do
    assigns = assign_defaults(assigns, @defaults, @attrs)

    ~H"""
      <div {@attrs}>Hello <%= @name %></div>
    """
  end
end

Note that in my example attrs stay in the assigns map. I like it that way because I find it useful sometimes. It would be easy to take them out as well if that’s your preference.

Also, if I want to “pass through” the additional attrs with out any default attrs I just send through an empty map %{}.

3 Likes

LiveView v0.16 introduced a wonderful new function on_mount that makes defining “default assigns” more elegant. The docs say it all:
https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#on_mount/1

Just so people don’t get confused…

What you are mentioning is a solution to a different problem.
It is a great way to add defaults to a LiveView, but we’re talking about pure function components which are a new concept in Phoenix 0.16.

https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html

1 Like

Ah yes, thanks for explaining that. :+1: