Writing good components

Core Principles

  • Stateless vs. Stateful

    • PanelComponents.* → stateless, domain-agnostic, function components.
    • PanelWeb.* → stateful LiveComponents or LiveViews, domain-aware, eventful.
  • API clarity

    • Always declare attr and slot.
    • Avoid raw assigns maps with hidden keys.
  • Domain separation

    • Components never call contexts or work directly with %Schema{}.
    • Map domain data → assigns in PanelWeb wrappers or presenters.

Attributes vs. Slots

When to use attributes

Use attr for configuration:

  • Boolean toggles (disabled, open)
  • Enumerations (variant={:primary}, status={:allocated})
  • Simple data values (title="Orders", count=5)

DO

<.button variant={:secondary} disabled>Cancel</.button>

DON’T

<.button><:disabled>true</:disabled>Cancel</.button>


⸻

When to use slots

Use slot for structure and content:
	•	Regions (:title, :meta, :actions)
	•	Repeated/nested items (:row, :compartment)
	•	Arbitrary inner markup

DO

<.card>
  <:title>Order #123</:title>
  <:actions><.button>Ship</.button></:actions>
  <:content>Items go here…</:content>
</.card>

DON’T

<.card title="Order #123" actions="<.button>Ship</.button>">
  Items go here…
</.card>


⸻

Classes convention
	•	Every component must accept a :class attr → applied to its root element.
	•	Every slot should accept a :class attr → applied to the slot’s immediate parent element.

Example: Card component

defmodule PanelComponents.Card do
  use Phoenix.Component

  attr :class, :string, default: ""
  slot :title, doc: "Card title", class: "mb-2 font-semibold"
  slot :actions, doc: "Action buttons", class: "flex gap-2"
  slot :content, required: true, class: "mt-3"

  def card(assigns) do
    ~H"""
    <div class={["rounded-lg border bg-white p-4", @class]}>
      <%= if @title != [] do %>
        <div class={slot_classes(@title)}>
          <%= render_slot(@title) %>
        </div>
      <% end %>

      <%= if @actions != [] do %>
        <div class={slot_classes(@actions)}>
          <%= render_slot(@actions) %>
        </div>
      <% end %>

      <div class={slot_classes(@content)}>
        <%= render_slot(@content) %>
      </div>
    </div>
    """
  end
end

Usage:

<.card class="shadow-lg">
  <:title class="text-lg">Order #123</:title>
  <:actions class="justify-end">
    <.button>Ship</.button>
  </:actions>
  <:content class="prose">
    Item list…
  </:content>
</.card>


⸻

Archetypes

Button
	•	Attrs: variant, size, disabled, class
	•	Slots: inner_block

defmodule PanelComponents.Button do
  use Phoenix.Component
  attr :variant, :atom, default: :primary, values: [:primary, :secondary]
  attr :size, :atom, default: :md, values: [:sm, :md, :lg]
  attr :disabled, :boolean, default: false
  attr :class, :string, default: ""
  slot :inner_block, required: true

  def button(assigns) do
    ~H"""
    <button
      class={[
        "rounded px-3 py-1",
        variant_class(@variant),
        size_class(@size),
        @class
      ]}
      disabled={@disabled}>
      <%= render_slot(@inner_block) %>
    </button>
    """
  end
end


⸻

Card

(see example under Classes convention)

⸻

Modal
	•	Attrs: open, class
	•	Slots: :title, :actions, :content

<.modal open={@open} class="w-1/2">
  <:title>Confirm action</:title>
  <:content>Are you sure?</:content>
  <:actions><.button>OK</.button></:actions>
</.modal>


⸻

List
	•	Attrs: class
	•	Slots: :row (repeated, yields item)

<.list items={@orders}>
  <:row :let={order}>
    <.card>
      <:title><%= order.name %></:title>
    </.card>
  </:row>
</.list>


⸻

Quick DOs and DON’Ts
	•	DO:

<.gate status={:allocated}><:title>Gate A</:title></.gate>


	•	DON’T:

<.gate transfer={Warehouse.get_transfer()} />


	•	DO: Keep composable function components small, dumb, declarative.
	•	DON’T: Put business logic, queries, or context calls inside them.
First published 2025-09-12
Last updated