Livex.LivexComponent (livex v0.1.2)

A module that enhances Phoenix LiveComponent with automatic state management and lifecycle improvements.

Features

  • Declarative State Definition: Define component state properties with type safety
  • Component Properties (attr): Define expected properties passed from parent
  • Simplified Lifecycle: Consolidate data derivation logic in the pre_render callback
  • Dependency-Aware Assignments: Use assign_new/4 to compute values only when dependencies change
  • Simplified State Updates: Use JSX.assign_state for direct state updates from templates
  • Component Events: Emit events to parent views with push_emit or JSX.emit
  • PubSub Integration: Subscribe to topics with dependency-aware assign_topic

State and Properties

Livex components use two key concepts for managing data:

  1. state - Internal component state that persists across renders
  2. attr - Properties passed down from parent components/views

Component State

# Client-side state, survives reconnects but not refreshes
state :is_expanded, :boolean
state :selected_tab, :string
state :current_count, :integer

Component Properties (attr)

# Properties passed from parent
attr :id, :string, required: true
attr :item_id, :string
attr :initial_value, :string
attr :on_save, :any

Lifecycle Management

Livex simplifies the component lifecycle by consolidating data derivation and initial assignment logic into a pre_render/1 callback. This is where you can initialize state based on attr values or compute derived data.

def pre_render(socket) do
  {:noreply,
   socket
   |> assign_new(:is_expanded, fn -> false end)
   |> assign_new(:pending_selection, [:selected_value], &(&1.selected_value))
   |> assign_new(:has_changes, fn -> false end)
   |> then(fn socket ->
     # Compute derived state
     assign(socket, :has_changes, 
       socket.assigns.pending_selection != socket.assigns.selected_value)
   end)}
end

Simplified State Updates

Livex introduces JSX.assign_state for updating component state directly from templates:

# Update a single value
<button phx-click={JSX.assign_state(:is_expanded, true)}>Expand</button>

# Update multiple values
<button phx-click={JSX.assign_state(is_expanded: false, selected_tab: "details")}>
  Close
</button>

# Conditional updates
<button phx-click={
  if @is_expanded do
    JSX.assign_state(is_expanded: false, pending_value: @initial_value)
  else
    JSX.assign_state(is_expanded: true)
  end
}>
  {if @is_expanded, do: "Close", else: "Expand"}
</button>

Component Events

Livex enhances event handling for components, allowing them to emit custom events that parent LiveViews or LiveComponents can listen for:

Emitting from templates:

<button phx-click={JSX.emit(:save, value: %{id: @id, data: @form_data})}>
  Save Changes
</button>

Emitting from Elixir code:

def handle_event("save_changes", _, socket) do
  # Process the data...
  
  # Emit an event to the parent
  socket = push_emit(socket, :saved, %{id: socket.assigns.id})
  {:noreply, assign(socket, :is_saving, false)}
end

Handling in parent:

<.live_component
  module={MyApp.FormComponent}
  id="my-form"
  phx-saved="handle_form_saved"
/>

# In the parent's handle_event
def handle_event("handle_form_saved", %{"id" => id}, socket) do
  # Handle the saved event
  {:noreply, socket}
end

PubSub Integration

Livex makes it easier for components to subscribe to PubSub topics with assign_topic:

def pre_render(socket) do
  {:noreply,
   socket
   |> assign_new(:status_message, fn -> "Connecting..." end)
   |> assign_topic(:doc_updates, [:document_id], fn assigns ->
     "document_updates:#{assigns.document_id}"
   end)}
end

# Handle PubSub messages
def handle_info({:doc_updates, %{message: msg}}, socket) do
  {:noreply, assign(socket, :status_message, msg)}
end

Complete Example

defmodule MyApp.FormComponent do
  use Livex.LivexComponent
  
  attr :id, :string, required: true
  attr :item_id, :string
  attr :on_save, :any
  
  state :is_expanded, :boolean
  state :form_data, :map
  state :is_valid, :boolean
  
  def pre_render(socket) do
    {:noreply,
     socket
     |> assign_new(:is_expanded, fn -> false end)
     |> assign_new(:form_data, [:item_id], fn assigns ->
       if assigns.item_id, do: load_item(assigns.item_id), else: %{}
     end)
     |> assign_new(:is_valid, [:form_data], fn assigns ->
       validate_form(assigns.form_data)
     end)}
  end
  
  def render(assigns) do
    ~H"""
    <div id={@id} class="form-component">
      <h3>Edit Item</h3>
      
      <div class="form-fields">
        <!-- Form fields here -->
      </div>
      
      <div class="actions">
        <button 
          disabled={!@is_valid}
          phx-click="save"
          phx-target={@myself}>
          Save
        </button>
        
        <button phx-click={JSX.emit(:cancel)}>
          Cancel
        </button>
      </div>
    </div>
    """
  end
  
  def handle_event("save", _, socket) do
    # Process the save...
    if socket.assigns.on_save do
      socket.assigns.on_save.(socket.assigns.form_data)
    end
    
    # Emit an event to the parent
    socket = push_emit(socket, :saved, %{id: socket.assigns.id})
    {:noreply, socket}
  end
  
  defp load_item(id) do
    # Load item data
  end
  
  defp validate_form(data) do
    # Validate form data
  end
end

Summary

Functions

inject_after_div(rendered, module, assigns)

override_update(assigns, socket, _, _)

override_update(assigns, socket, current_params, module, super)