Svelte 5 components inside Phoenix LiveView via LiveSvelte. Covers reactivity, props from LiveView, live.pushEvent, SSR, slots, and end-to-end reactivity patterns. Use when building Svelte components in a Phoenix project.
Expert guidance for building Svelte 5 components inside Phoenix LiveView using LiveSvelte for end-to-end reactivity.
assets/svelte/ — they receive props from LiveView over the websocket$state, $derived, $effect) — not legacy $: reactive declarations# LiveView
defmodule MyAppWeb.CounterLive do
use MyAppWeb, :live_view
def render(assigns) do
~H"""
<.svelte name="Counter" props={%{number: @number}} socket={@socket} />
"""
end
def mount(_params, _session, socket) do
{:ok, assign(socket, :number, 0)}
end
def handle_event("set_number", %{"number" => number}, socket) do
{:noreply, assign(socket, :number, number)}
end
end
<!-- assets/svelte/Counter.svelte -->
<script lang="ts">
// Props come from LiveView assigns — reactive over the websocket
let { number, live } = $props();
function increase() {
// Push event to LiveView — server updates the assign — prop flows back
live.pushEvent("set_number", { number: number + 1 }, () => {});
}
function decrease() {
live.pushEvent("set_number", { number: number - 1 }, () => {});
}
</script>
<p>The number is {number}</p>
<button onclick={increase}>+</button>
<button onclick={decrease}>-</button>
Use LiveSvelte.Components for a more JSX-like experience in HEEx:
defmodule MyAppWeb.DashboardLive do
use MyAppWeb, :live_view
use LiveSvelte.Components
def render(assigns) do
~H"""
<.Counter number={@number} socket={@socket} />
<.Chart data={@chart_data} socket={@socket} />
"""
end
end
Use ~V instead of ~H to write Svelte directly in your LiveView:
defmodule MyAppWeb.InlineSvelteLive do
use MyAppWeb, :live_view
def render(assigns) do
~V"""
<script>
export let count = 0
let local_state = "only in svelte"
</script>
<p>Count from server: {count}</p>
<p>Local: {local_state}</p>
<button phx-click="increment">Server increment</button>
<button on:click={() => local_state = "changed"}>Local change</button>
"""
end
def mount(_params, _session, socket) do
{:ok, assign(socket, :count, 0)}
end
def handle_event("increment", _value, socket) do
{:noreply, assign(socket, :count, socket.assigns.count + 1)}
end
end
live ObjectThe live prop is automatically injected into every LiveSvelte component. Available methods:
| Method | Description |
|---|---|
live.pushEvent(event, payload, callback) | Push event to the parent LiveView |
live.pushEventTo(selector, event, payload, callback) | Push event to a specific LiveView/component |
live.handleEvent(event, callback) | Listen for server-pushed events |
live.removeHandleEvent(ref) | Remove an event listener |
live.upload(name, files) | Upload files |
live.uploadTo(selector, name, files) | Upload files to a specific component |
Important: These methods only work on the client. Wrap in onMount or call from event handlers — never at the top level (SSR will fail).
<script lang="ts">
import { onMount } from "svelte";
let { live } = $props();
let notifications = $state<string[]>([]);
onMount(() => {
// Listen for server-pushed events
const ref = live.handleEvent("new_notification", (payload) => {
notifications = [...notifications, payload.message];
});
return () => live.removeHandleEvent(ref);
});
function dismiss(index: number) {
live.pushEvent("dismiss_notification", { index }, () => {});
}
</script>
<script lang="ts">
// Props from LiveView (reactive over websocket)
let { items, live } = $props();
// Local UI state (not sent to server)
let filter = $state("all");
let searchQuery = $state("");
// Derived from props + local state
let filteredItems = $derived(
items.filter((i) =>
(filter === "all" || i.status === filter) &&
i.name.toLowerCase().includes(searchQuery.toLowerCase())
)
);
let count = $derived(filteredItems.length);
</script>
<script lang="ts">
let { query } = $props();
// ✅ Good: Effect for side effects (e.g., focus, scroll, external libs)
$effect(() => {
if (query) {
document.title = `Search: ${query}`;
}
return () => { document.title = "My App"; };
});
// ❌ Bad: Using $effect to derive state
// let count = $state(0);
// $effect(() => { count = items.length }); // Use $derived instead!
</script>
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
title: string;
variant?: "primary" | "secondary";
children?: Snippet;
onclick?: () => void;
}
let { title, variant = "primary", children, onclick }: Props = $props();
</script>
<div class="card {variant}">
<h2>{title}</h2>
{#if children}
{@render children()}
{/if}
{#if onclick}
<button onclick={onclick}>Action</button>
{/if}
</div>
Slot HEEx content from LiveView into Svelte components:
# LiveView template
<.svelte name="Card">
<p>This HEEx content is slotted into Svelte</p>
</.svelte>
# Named slots
<.svelte name="Card">
Main content here
<:subtitle>
<p>Subtitle from LiveView</p>
</:subtitle>
</.svelte>
<!-- assets/svelte/Card.svelte -->
<script lang="ts">
let { children, subtitle } = $props();
</script>
<div class="card">
{@render children?.()}
{#if subtitle}
<h3>{@render subtitle()}</h3>
{/if}
</div>
Note: Slotted content is wrapped in a <div> by LiveSvelte (limitation of createRawSnippet).
SSR is enabled by default. On first page load, Svelte renders HTML on the server, then hydrates on the client.
# Per component
<.svelte name="HeavyChart" ssr={false} props={%{data: @data}} socket={@socket} />
# Globally in config.exs
config :live_svelte, ssr: false
live.pushEvent and other live methods don't work during SSR — wrap in onMountwindow, document, localStorage don't exist during SSR — guard with onMount or browser checkNODE_ENV=production in production deployments to avoid memory leaks during SSR<script lang="ts">
import { onMount } from "svelte";
let { live } = $props();
let mounted = $state(false);
onMount(() => {
mounted = true;
// Safe to use browser APIs and live methods here
});
</script>
{#if mounted}
<InteractiveWidget />
{:else}
<LoadingPlaceholder />
{/if}
For large JSON payloads, use live_json to send diffs instead of full objects:
def render(assigns) do
~H"""
<.svelte name="DataTable" live_json_props={%{rows: @ljrows}} socket={@socket} />
"""
end
def mount(_, _, socket) do
{:ok, LiveJson.initialize("rows", large_dataset())}
end
def handle_info({:data_updated, new_data}, socket) do
{:noreply, LiveJson.push_patch(socket, "rows", new_data)}
end
Only use live_json for large, frequently-changing data. For small payloads, regular props are cheaper.
LiveSvelte serializes props to JSON. With OTP 27+ native JSON (default), structs are auto-converted to maps.
If using Jason, add @derive:
defmodule MyApp.Task do
use Ecto.Schema
# Only include fields safe for the client
@derive {Jason.Encoder, except: [:__meta__, :password_hash]}
schema "tasks" do
field :title, :string
field :status, :string
timestamps()
end
end
{#if loading}
<Spinner />
{:else if error}
<p class="error">{error}</p>
{:else if items.length === 0}
<p>No items found.</p>
{:else}
{#each items as item (item.id)}
<ItemCard {item} ondelete={() => live.pushEvent("delete", { id: item.id }, () => {})} />
{/each}
{/if}
<!-- push_navigate (different LiveView) -->
<a href="/other-page" data-phx-link="redirect" data-phx-link-state="push">Go</a>
<!-- push_patch (same LiveView, different params) -->
<a href="/current?tab=settings" data-phx-link="patch" data-phx-link-state="push">Settings</a>
Useful for preserving Svelte store state across navigation.
<style>
/* Scoped by default */
.card { padding: 1rem; border-radius: 0.5rem; }
/* Use :global() to escape scoping (e.g., style slotted HEEx content) */
:global(.from-liveview) { color: blue; }
</style>
<!-- Dynamic classes -->
<div class="card" class:active={isActive}>
Unlike LiveView (which only sends HTML over the wire), LiveSvelte sends JSON data to the client. Svelte code with conditionals will contain the logic for all branches — even hidden ones.
<!-- ❌ The admin panel code is visible in the JS bundle even when !isAdmin -->
{#if isAdmin}
<AdminPanel {secretData} />
{/if}
<!-- ✅ Don't send secret data as props — handle it server-side in LiveView -->
Rule: Never send sensitive data as props. Use LiveView's server-side rendering for anything that should stay hidden.
<!-- ❌ Don't set state that should come from the server -->
<script>
let { number, live } = $props();
function increase() {
number++; // Local only! Server doesn't know about this
}
</script>
<!-- ✅ Push to server, let props flow back -->
<script>
let { number, live } = $props();
function increase() {
live.pushEvent("increment", {}, () => {});
}
</script>
<!-- ❌ Don't call live methods at top level (breaks SSR) -->
<script>
let { live } = $props();
live.handleEvent("update", () => {}); // Fails during SSR!
</script>
<!-- ✅ Wrap in onMount -->
<script>
import { onMount } from "svelte";
let { live } = $props();
onMount(() => {
live.handleEvent("update", () => {});
});
</script>
<!-- ❌ Don't use $effect to derive state -->
<script>
let items = $state([]);
let count = $state(0);
$effect(() => { count = items.length }); // Wrong!
</script>
<!-- ✅ Use $derived -->
<script>
let items = $state([]);
let count = $derived(items.length);
</script>
<!-- ❌ Don't forget keys in each blocks -->
{#each items as item}
<!-- ✅ Always provide a key -->
{#each items as item (item.id)}
assets/
├── svelte/
│ ├── Counter.svelte # Simple components at root
│ ├── Chart.svelte
│ ├── components/ # Shared sub-components
│ │ ├── Button.svelte
│ │ └── Modal.svelte
│ └── dashboard/ # Feature directories
│ ├── DashboardStats.svelte
│ └── DashboardChart.svelte
Reference nested components in LiveView: <.svelte name="dashboard/DashboardStats" ... />