DEV Community

Cover image for Refactoring Phoenix Components
Mykolas Mankevicius
Mykolas Mankevicius

Posted on

Refactoring Phoenix Components

Refactoring Chat Bubbles in Phoenix LiveView: From Inline to Composable

Inline LiveView components have a way of growing organically until suddenly you're staring at duplicated styling logic everywhere. Here's how we refactored our chat bubbles into something composable.

The Problem

We had chat bubbles scattered across our codebase with duplicated styling and inconsistent APIs:

# Before: Inline styling in chat_ui.ex
defp left_bubble(assigns) do
  ~H"""
  <div class="flex flex-col gap-1">
    <div class="flex items-end gap-2.5">
      <.link navigate={~q"/:locale/shop/#{@user}"} class="shrink-0">
        <UserUI.avatar user={@user} variant={:h_6} class="shrink-0" />
      </.link>
      <div class={[
        @variant == :default && "bg-gray-100 after:border-r-gray-100",
        @variant == :offer && "min-w-[265px] bg-dark-primary text-inverted after:border-r-dark-primary",
        "relative flex w-fit flex-col rounded-lg rounded-bl-none p-2",
        "after:content-[''] after:border-t-[1em] after:border-r-[1em] after:absolute after:bottom-0 after:left-0 after:h-0 after:w-0 after:-translate-x-1/2 after:border-t-transparent"
      ]}>
        {render_slot(@inner_block)}
      </div>
    </div>
    <div class="ml-8">
      <Text.caption text={@timestamps} class="text-secondary" />
    </div>
  </div>
  """
end
Enter fullscreen mode Exit fullscreen mode

This pattern was repeated for right_bubble/1 with mirrored styling. The main issue: business domain logic tied directly to styling. Variants like :default and :offer are domain concepts, but here they're mixed with CSS classes and layout decisions. Changing how an "offer" looks means hunting through conditionals.

The Solution: Extract a Reusable Component

1. Define Clear Attributes and Slots

defmodule MarkoUI.Components.ChatBubble do
  use MarkoUI, :component

  attr :side, :string, required: true, values: ["left", "right"]
  attr :color, :string, default: "default", values: ["default", "purple"]

  slot :inner_block, required: true
  slot :affix
  slot :caption

  @spec bubble(map()) :: Rendered.t()
  def bubble(assigns) do
    ~H"""
    <div data-side={@side} class="group/bubble flex items-end gap-2 data-right:flex-row-reverse">
      <div class="shrink-0">
        {render_slot(@affix)}
      </div>

      <div
        style={bubble_style(@color)}
        class="min-w-[264px] bg-[var(--color-bg)] border-[var(--color-border)] relative w-fit rounded-t-lg border group-data-left/bubble:rounded-r-lg group-data-right/bubble:rounded-l-lg"
      >
        {render_slot(@inner_block)}
        <.bubble_tail side={@side} />
        <div :if={@caption != []} class="absolute bottom-0 translate-y-full pt-1 group-data-left/bubble:left-0 group-data-right/bubble:right-0">
          {render_slot(@caption)}
        </div>
      </div>
    </div>
    """
  end
end
Enter fullscreen mode Exit fullscreen mode

Named slots (affix, inner_block, caption) give callers flexibility. The bubble_tail/1 private component handles that fiddly CSS triangle—it uses the same CSS custom properties so colors stay in sync automatically.

2. Use CSS Custom Properties for Theming

Instead of conditional classes like @variant == :default && "bg-gray-100", we use inline custom properties:

defp bubble_style(color) do
  case color do
    "default" -> "--color-bg: #ffffff; --color-border: #030303; --color-hover: #f6f5f7; color: #030303;"
    "purple" -> "--color-bg: #5454DF; --color-border: #ffffff; --color-hover: #6969e0; color: #ffffff;"
    "black" -> "--color-bg: #030303; --color-border: #ffffff; --color-hover: #2b2b2b; color: #ffffff;"
  end
end
Enter fullscreen mode Exit fullscreen mode

The bubble tail inherits colors automatically, and adding new variants is just one function clause.

3. Create Composable Sub-Components

@spec content(map()) :: Rendered.t()
def content(assigns) do
  ~H"""
  <div class="rounded-lg p-2">
    {render_slot(@inner_block)}
  </div>
  """
end

attr :label, :string, default: nil
attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
slot :inner_block, required: true

@spec action(map()) :: Rendered.t()
def action(%{rest: rest} = assigns) do
  assigns =
    assign(assigns, :class,
      "peer bg-[var(--color-bg)] border-[var(--color-border)] text-body-1 block w-full border-t py-3 text-center group-data-left/bubble:rounded-br-lg group-data-right/bubble:rounded-bl-lg hover:bg-[var(--color-hover)]"
    )

  if rest[:href] || rest[:navigate] || rest[:patch] do
    ~H"""
    <.link class={@class} {@rest}>
      {@label || render_slot(@inner_block)}
    </.link>
    """
  else
    ~H"""
    <button type="button" class={@class} {@rest}>
      {@label || render_slot(@inner_block)}
    </button>
    """
  end
end
Enter fullscreen mode Exit fullscreen mode

The action/1 component uses attr :rest, :global to accept navigation attributes, then conditionally renders a <.link> or <button>. It inherits colors from the parent via those CSS custom properties.

4. Refactor the Consumer

Now chat_ui.ex becomes much cleaner:

# After: Using the ChatBubble component
defp left_bubble(assigns) do
  ~H"""
  <ChatBubble.bubble side="left" color={variant_to_color(@variant)}>
    <:affix>
      <.link navigate={~q"/:locale/shop/#{@user}"}>
        <UserUI.avatar user={@user} variant={:h_6} class="shrink-0" />
      </.link>
    </:affix>
    <ChatBubble.content>
      {render_slot(@inner_block)}
    </ChatBubble.content>
    <:caption>
      <ChatBubble.caption text={@timestamps} />
    </:caption>
  </ChatBubble.bubble>
  """
end

defp variant_to_color("default"), do: "purple"
defp variant_to_color("offer"), do: "black"
Enter fullscreen mode Exit fullscreen mode

ChatBubble handles styling, ChatUI handles business logic, and variant_to_color/1 bridges domain concepts to presentation.

5. Build Higher-Level Abstractions

Now we can create domain-specific components on top:

@spec system_bubble(map()) :: Rendered.t()
def system_bubble(assigns) do
  ~H"""
  <ChatBubble.bubble side="left" color="default">
    <:affix>
      <div class="size-6 bg-brand-3-primary rounded-full">
        <SVG.icon name="other-logo-marko-icon" class="size-full" />
      </div>
    </:affix>

    <ChatBubble.content>
      <div class="flex flex-col gap-2">
        <Text.caption :if={@title} bold text={@title} />
        {render_slot(@inner_block)}
      </div>
    </ChatBubble.content>
    {render_slot(@content)}

    <:caption>
      <ChatBubble.caption text={@caption || DateTimeHelper.chat_time(DateTime.utc_now())} />
    </:caption>
  </ChatBubble.bubble>
  """
end
Enter fullscreen mode Exit fullscreen mode

Fixed affix, optional title, auto-generated timestamp—all the system message defaults baked in.

Using data-* Attributes for Variant Styling

A pattern worth highlighting:

<div data-side={@side} class="group/bubble flex items-end gap-2 data-right:flex-row-reverse">
Enter fullscreen mode Exit fullscreen mode

Tailwind's data-* variants let you conditionally style without runtime class concatenation. Combined with group/bubble, children can respond to parent state:

class="group-data-left/bubble:rounded-br-lg group-data-right/bubble:rounded-bl-lg"
Enter fullscreen mode Exit fullscreen mode

Less logic, less data down the socket.

Key Takeaways

  1. Extract when you see a clear pattern — sometimes it's best to wait for 3 variants to see the underlying API. We needed system_bubble and that forced the decision.
  2. Use slots for flexibility — named slots let consumers customize specific parts.
  3. CSS custom properties beat class explosion — one style function instead of scattered conditionals.
  4. Separate domain from presentation — a mapping layer like variant_to_color/1 keeps business concepts out of your styling code.
  5. Layer your abstractions — base component (ChatBubble) → domain component (system_bubble).
  6. data-* attributes for variants — cleaner than runtime class concatenation.
  7. Component libraries are documentation — a dedicated page showing all variants catches regressions and exposes design flaws.

P.S.

You really don't need complicated setups for testing components. Simple LiveViews with a few handlers is all it takes. Storybook and friends are beautiful abstractions, but often overkill. These test pages don't have to be pretty—they'll expose flaws in your component design when you feel resistance using them. :D

One more thing: you'll notice I use string variants instead of atoms throughout. Atoms are great for Elixir code, but they're limited in number and never garbage collected—no need to eat into that space for component APIs. Plus side="left" is one character less than side={:left}, and you can copy-paste to React/Vue/Svelte if you're into that. :D

P.S.S

This post was written with the help of AI, but I curate and review everything thoroughly—the ideas, code, and opinions are all mine.

Top comments (0)