Skip to content

Instantly share code, notes, and snippets.

@hugobarauna
Last active September 24, 2025 03:29
Show Gist options
  • Save hugobarauna/946e34fdb155631704f66912ced74611 to your computer and use it in GitHub Desktop.
Save hugobarauna/946e34fdb155631704f66912ced74611 to your computer and use it in GitHub Desktop.
LiveView + Fluxon Accordion State Management Issue & Potential Enhancement

LiveView + Fluxon Accordion State Management Issue & Potential Enhancement

Hey! 👋

I ran into an interesting issue with Fluxon's accordion component in a Phoenix LiveView app and wanted to share the findings - there might be an opportunity to make Fluxon even more LiveView-friendly.

The Problem

When using <.accordion> with form validation (phx-change="validate"), the accordion would collapse every time the user typed in the form input. This created a frustrating UX where users couldn't keep the form expanded while typing.

Root Cause: Fluxon's accordion stores state in private JavaScript variables (#openIndexes). When LiveView re-renders the DOM (even for form validation), the hook lifecycle destroys and recreates the accordion instance, losing the internal state.

Sequence:

  1. User expands accordion → Fluxon sets #openIndexes = [0]
  2. User types in form → LiveView validation triggers DOM patch
  3. Hook destroyed → #openIndexes lost
  4. New hook mounted → Reads template expanded={false} → Accordion collapses

Our Solution

We ended up building a simple custom accordion that uses data attributes as the state store instead of private JS variables:

// State lives in DOM, survives LiveView patches
this.el.dataset.expanded = "true"; 

updated() {
  // Restore from data attribute after LiveView updates
  const expanded = this.el.dataset.expanded === 'true';
  this.setState(expanded);
}

This works because:

  • DOM data attributes survive LiveView patches
  • No complex state synchronization needed
  • Follows LiveView's recommended patterns

Potential Fluxon Enhancement

I wonder if Fluxon could add a "LiveView mode" that:

  1. Uses data attributes for state persistence instead of (or alongside) private variables
  2. Reads state from data attributes in updated() hook to survive DOM patches
  3. Maybe adds a liveview-friendly prop that enables this behavior

Something like:

<.accordion liveview-friendly>
  <.accordion_item expanded={@form_expanded}>

Where the accordion would:

  • Store expanded state in data-expanded attributes
  • Restore state from data attributes in the updated() lifecycle
  • Still provide the same rich functionality (animations, keyboard nav, etc.)

Why This Matters

This pattern (form validation causing component state loss) is probably common in LiveView apps. A LiveView-friendly mode could make Fluxon components more robust in this environment while maintaining all the great accessibility and animation features.

The LiveView docs actually recommend this pattern for client-side libraries:

"Changes, additions, and removals from the server to data attributes are merged with the ignored element which can be used to pass data to the JS handler."

Happy to Help

If you're interested in exploring this, I'd be happy to:

  • Share more details about the implementation
  • Help test any changes
  • Contribute code if needed

The current Fluxon components are awesome - this would just make them even more LiveView-friendly! 🚀

Key Implementation Details

The Problem with Fluxon Accordion + LiveView

What Happens:

  1. User expands accordion → Fluxon stores state in #openIndexes = [0]
  2. User types in form → phx-change="validate" triggers LiveView DOM patch
  3. LiveView hook destroyed → Private state #openIndexes is lost
  4. New hook mounted → Reads template expanded={false} → Accordion collapses

Why It Happens:

  • Fluxon uses private JavaScript state (#openIndexes) that doesn't survive DOM patches
  • LiveView DOM patching destroys and recreates hook instances
  • No persistence mechanism for client-side state across LiveView updates

Our Solution: Data Attributes as State Store

Key Principles:

  1. DOM data attributes survive LiveView patches - they're the persistence layer
  2. Immediate client-side updates - no round trips for UI interactions
  3. Server controls initial state - via @form_expanded assign
  4. Hook restores state after updates - in updated() lifecycle

Code Flow:

1. Server Side (LiveView)

# Server controls initial state
assign(:form_expanded, true)

# Template passes state to client via data attribute
<div data-expanded={@form_expanded} phx-hook="SimpleAccordion">

2. Client Side (Hook)

toggle() {
  // Update UI immediately (no round trip)
  this.setState(newState);
  
  // Save to DOM for persistence across LiveView updates
  this.el.dataset.expanded = newState.toString();
}

updated() {
  // Restore state after LiveView DOM patch
  const saved = this.el.dataset.expanded === 'true';
  this.setState(saved);
}

Why This Works:

  • 1ms toggle response (immediate client-side update)
  • Survives LiveView updates (data attributes persist)
  • No focus loss (minimal DOM manipulation)
  • No flickering (smooth state transitions)
  • Server authority (initial state controlled by LiveView)

Potential Fluxon Enhancement

Adding LiveView-friendly mode could make this seamless:

<.accordion liveview-friendly>
  <.accordion_item expanded={@server_controlled_state}>

Where liveview-friendly mode would:

  1. Use data-expanded attributes for state persistence
  2. Restore state in updated() hook lifecycle
  3. Keep all existing Fluxon features (animations, accessibility, etc.)
defmodule ToDoWeb.ListLive.Form do
use ToDoWeb, :live_view
alias ToDo.Lists
alias ToDo.Lists.List
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
{@page_title}
<:subtitle>Use this form to manage list records in your database.</:subtitle>
</.header>
<div id="simple-accordion" class="simple-accordion" phx-hook="SimpleAccordion" data-expanded={@form_expanded}>
<button
type="button"
data-accordion-button
class="flex w-full items-center justify-between py-4 text-left font-medium text-foreground hover:text-foreground-soft transition-colors"
aria-expanded={@form_expanded}
>
<span>Form</span>
<svg class="size-4 transform transition-transform" aria-hidden="true" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m6 9 6 6 6-6" />
</svg>
</button>
<div
data-accordion-panel
class={["overflow-hidden transition-all", unless(@form_expanded, do: "hidden")]}
>
<div class="pb-4">
<.form for={@form} id="list-form" phx-change="validate" phx-submit="save">
<.input field={@form[:title]} type="text" label="Title" />
<footer class="mt-4 flex gap-2">
<.button phx-disable-with="Saving..." variant="solid">Save List</.button>
<.button navigate={return_path(@return_to, @list)}>Cancel</.button>
</footer>
</.form>
</div>
</div>
</div>
</Layouts.app>
"""
end
@impl true
def mount(params, _session, socket) do
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
|> assign(:form_expanded, true)
|> apply_action(socket.assigns.live_action, params)}
end
defp return_to("show"), do: "show"
defp return_to(_), do: "index"
defp apply_action(socket, :edit, %{"id" => id}) do
list = Lists.get_list!(id)
socket
|> assign(:page_title, "Edit List")
|> assign(:list, list)
|> assign(:form, to_form(Lists.change_list(list)))
end
defp apply_action(socket, :new, _params) do
list = %List{}
socket
|> assign(:page_title, "New List")
|> assign(:list, list)
|> assign(:form, to_form(Lists.change_list(list)))
end
@impl true
def handle_event("accordion_toggled", %{"index" => index, "expanded" => expanded}, socket) do
# For now, we only care about the first item (index 0)
if index == 0 do
{:noreply, assign(socket, form_expanded: expanded)}
else
{:noreply, socket}
end
end
def handle_event("toggle_form", _, socket) do
{:noreply, assign(socket, form_expanded: !socket.assigns.form_expanded)}
end
def handle_event("validate", %{"list" => list_params}, socket) do
changeset = Lists.change_list(socket.assigns.list, list_params)
# Preserve form expanded state during validation
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
end
def handle_event("save", %{"list" => list_params}, socket) do
save_list(socket, socket.assigns.live_action, list_params)
end
defp save_list(socket, :edit, list_params) do
case Lists.update_list(socket.assigns.list, list_params) do
{:ok, list} ->
{:noreply,
socket
|> put_flash(:info, "List updated successfully")
|> push_navigate(to: return_path(socket.assigns.return_to, list))}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
defp save_list(socket, :new, list_params) do
case Lists.create_list(list_params) do
{:ok, list} ->
{:noreply,
socket
|> put_flash(:info, "List created successfully")
|> push_navigate(to: return_path(socket.assigns.return_to, list))}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
defp return_path("index", _list), do: ~p"/lists"
defp return_path("show", list), do: ~p"/lists/#{list}"
end
// Simple custom accordion hook - immediate toggle, state preservation
const SimpleAccordionHook = {
mounted() {
console.log('SimpleAccordion mounted');
this.setupAccordion();
},
updated() {
// Restore state after LiveView updates
this.restoreState();
},
setupAccordion() {
const button = this.el.querySelector('[data-accordion-button]');
const panel = this.el.querySelector('[data-accordion-panel]');
if (!button || !panel) return;
button.addEventListener('click', () => {
this.toggle();
});
// Set initial state
this.restoreState();
},
toggle() {
const button = this.el.querySelector('[data-accordion-button]');
const panel = this.el.querySelector('[data-accordion-panel]');
const isExpanded = button.getAttribute('aria-expanded') === 'true';
const newState = !isExpanded;
// Update immediately (no round trip!)
this.setState(newState);
// Save state to dataset for LiveView persistence
this.el.dataset.expanded = newState.toString();
},
setState(expanded) {
const button = this.el.querySelector('[data-accordion-button]');
const panel = this.el.querySelector('[data-accordion-panel]');
button.setAttribute('aria-expanded', expanded.toString());
if (expanded) {
panel.style.display = 'block';
panel.classList.remove('hidden');
} else {
panel.style.display = 'none';
panel.classList.add('hidden');
}
},
restoreState() {
// Read state from LiveView data attribute
const savedState = this.el.dataset.expanded;
if (savedState) {
const expanded = savedState === 'true';
this.setState(expanded);
}
}
};
// Register the hook
const liveSocket = new LiveSocket("/live", Socket, {
hooks: {SimpleAccordion: SimpleAccordionHook, ...otherHooks}
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment