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
attrandslot. - Avoid raw
assignsmaps with hidden keys.
- Always declare
-
Domain separation
- Components never call contexts or work directly with
%Schema{}. - Map domain data → assigns in
PanelWebwrappers or presenters.
- Components never call contexts or work directly with
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.