If you’ve ever built a tabbed interface, stepped form, or sidebar-driven navigation in Phoenix LiveView, chances are you’ve run into a subtle but frustrating issue: your component re-initializes every time you switch “pages,” even though it’s technically part of the same LiveView. This happens because changing the URL — even via push_patch — triggers a re-mount of child components unless you carefully manage patching behavior.
On the surface, push_patch and live_patch seem straightforward. They let you update the browser’s URL bar without a full page reload, triggering handle_params/3 in the LiveView so you can react accordingly. But if your LiveView isn’t architected with patch-awareness in mind, you may see unwanted resets, re-renders, or even flashes of unloaded content as assigns are re-evaluated from scratch.
Let’s take an example: a user settings page with a sidebar. Each section — Profile, Password, Notifications — is its own tab. Clicking a tab calls push_patch, updating the route to /settings/profile, /settings/password, etc. The route updates, handle_params/3 fires, and the correct section is rendered. So far so good.
But let’s say the user is midway through updating their profile. They have unsaved inputs. If they accidentally click the Notifications tab and then go back to Profile, their unsaved changes are gone. Why? Because by default, your assigns are replaced each time handle_params/3 is triggered — unless you explicitly preserve them.
Here’s where the real understanding of LiveView patching begins.
When using push_patch, you’re telling LiveView: "Change the URL, but don’t reconnect the socket." That’s important. It means you can intercept the change inside handle_params/3 and decide exactly how to respond. But LiveView has no idea which of your assigns are transient (ephemeral UI state) and which are permanent (persistent backend state). If you naively reassign all assigns every time, your form inputs and local session state will disappear.
To preserve state across patch transitions, you need to structure your LiveView to initialize its state once in mount/3 and modify it only when truly necessary inside handle_params/3. That often means adding conditional logic: if the route has changed but the underlying resource hasn’t, avoid re-fetching it or re-initializing form data.
This pattern becomes even more powerful when paired with live_component or render/2 clause isolation. You can break your interface into patch-aware components, passing only minimal params to them, and letting each component control its own reactivity. The parent LiveView handles patching and routing, while child components remain mounted and stable — keeping their state intact between transitions.
Let’s push further. Imagine a Kanban board where each column is a LiveComponent. When you click a card, the URL patches to include the card ID, and a modal opens. That modal fetches card data and shows edit controls. But when you dismiss the modal — another patch — you don’t want the entire board to re-render. You want the card data to remain where it was, any unsaved form fields intact, and only the modal interface to change. This level of UI fidelity is possible, but only if you’ve structured your assigns and patches carefully.
Another common mistake is treating handle_params/3 like mount/3. They’re not the same. mount/3 is called once per connection; handle_params/3 is called on every patch. When you mix initialization logic into both, you get unexpected resets. The best approach is to treat mount/3 as the place to set up persistent assigns, like session tokens, initial resources, or default modes. Then, inside handle_params/3, apply only the minimal changes needed to reflect the URL state — typically a param like :section, :tab, or :id.
One practical tip is to maintain a separate struct or map inside your socket assigns for transient state — form data, in-progress edits, cursor positions, etc. You then explicitly preserve or clear that struct depending on the nature of the patch. For example, switching from one profile tab to another might retain the form state if both tabs deal with the same resource, but reset it if you navigate away to a different entity. Having that separation of state responsibilities gives you much finer control over the user experience.
You’ll also find that temporary_assigns and assign_new/3 are invaluable tools in patch-aware design. The former lets you keep parts of the socket lightweight and avoid memory buildup, especially when rendering large collections. The latter lets you lazily assign values only if they haven’t already been set — exactly the kind of guard you want when dealing with reentrant patch events.
All of this boils down to one principle: treat push_patch as a signal to respond, not as a reset trigger. The most robust LiveView apps react to URL changes like a router would — incrementally, surgically, and with full awareness of existing context. If your components blow away their state every time a route changes, you’re not building a reactive interface. You’re simulating one.
Building patch-aware interfaces is more than a technical trick. It’s a UX decision. The difference between a form that resets on every tab click and one that gracefully preserves input is the difference between user frustration and trust. The more complex your app gets — multiple layers of interaction, modals, tabs, editors, previews — the more valuable patch-aware design becomes.
In production-grade LiveView apps, this is no longer optional. Your users expect to move around fluidly without penalty. That means patching responsibly, designing with component lifecycle in mind, and taking full control over how and when your assigns change. LiveView gives you the tools. The rest is design discipline.
If you’re building Phoenix LiveView apps that need to scale not just technically but in terms of team and process, I’ve written a detailed PDF guide: Phoenix LiveView: The Pro’s Guide to Scalable Interfaces and UI Patterns. It’s a 20-page manual for designing LiveView apps that are maintainable, testable, and ready for collaboration. Whether you’re solo or part of a team, this guide will help you build LiveView systems that are a joy to work on — today and in the future.
Top comments (2)
Loved how you explain the difference between mount and handle_params - makes the patching dilemma so much clearer. Any tips for dealing with even deeper nested component state in big LiveView UIs?
Thanks for the kind words, Dotallio - glad the mount vs. handle_params distinction clarified the patching workflow for you!
When it comes to managing deeper nested state in large LiveView UIs, here are a few advanced strategies that can help maintain clarity and performance:
Decouple with Phoenix.Component and :let
Use Phoenix.Component (formerly function components) where possible. They don’t maintain their own socket, so they’re lightweight and excellent for presenting nested data. Combine this with :let assigns to selectively control the data each layer consumes.
Leverage LiveComponent's Isolated State
For truly interactive nested UIs, LiveComponents are indispensable. They allow encapsulated state and targeted updates via send_update/2, especially useful when only a portion of deeply nested state needs to respond to param changes or async events. Use update/2 carefully to manage input diffs rather than replacing entire state trees.
Pattern Match + handle_params Granularity
In large LiveViews, break down your handle_params/3 logic into pattern-matched private helpers that map tightly to your URL state. This isolates patch-driven navigation logic and keeps your LiveView lean—even when URL-driven state affects deeply nested structures.
Use assign_new/3 Strategically
If you're rendering nested components that depend on shared or persisted state (like user preferences, or layout modes), assign_new/3 can help initialize them only when needed, avoiding expensive recomputations on patch.
Track Nested State with a Normalized Shape
Avoid nesting state too deeply in the socket. Treat assigns like a flat store where nested data is accessed via IDs and selectors. You can mimic Redux-style normalization: for instance, store %{widgets_by_id: %{...}, widget_ids: [...]} instead of a giant tree of widgets with embedded children.
Use handle_info for Out-of-Band State Transitions
For nested components that require async data loading or user input (e.g. modals, tab panels), consider orchestrating state transitions via handle_info/2 and targeted send_update/2 calls instead of relying entirely on params or direct socket assigns.
Hope some of that helps!
Some comments may only be visible to logged-in visitors. Sign in to view all comments.