Integrate frontend frameworks into your Phoenix LiveView app
Phoenix LiveView lets you build dynamic web apps without writing much JavaScript. But sometimes, you do need a bit of client-side magic - especially for animations or local state that you don't want to store on the server.
Instead of throwing LiveView entirely out of the window, you can enhance it by embedding components from your favorite frontend frameworks like Svelte, Vue, or React. Of course, you can achieve similar results using client hooks but in practice, the code often becomes hard to manage, and juggling with phx-update="ignore"
can be annoying.
That’s where libraries like LiveSvelte, LiveVue, and LiveReact come in. These libraries allow you to embed reactive frontend components directly into your LiveView templates with minimal effort. What’s even better is that by using these packages, you're not limited to building everything from scratch. You gain access to the entire ecosystem of packages available for your chosen framework.
We’ll walk through some simple examples - using local state, passing content through slots, and even throwing in some LiveView-style sigils for good measure. The goal is to show how seamlessly these frontend components can blend into your LiveView workflows without giving up the things that make LiveView great.
Note: For simplicity, I’m leaving out styling in the code snippets.
How it works
To make everything work, each integration relies on a custom vite
or esbuild
configuration, which replaces the default one provided by LiveView.
When you render e.g. a Svelte component inside your template it looks something like this:
<.svelte name="Component" socket={@socket} props={%{count: @count}}>
<:header><h2>Header Slot</h2></:header>
<div><p>Inner (default) slot</p></div>
</.svelte>
You pass props via a map, and you can also define named slots just like in normal LiveView components. What, in this case, LiveSvelte does under the hood is convert this into a plain HTML <div>
with data attributes that the JS Hook can pick up on.
That ends up looking something like this in the rendered HTML:
<div
id="Component-8774"
data-name="Component"
data-props="{'count':0}"
data-slots="{'default':'base-64','header':'base-64'}"
phx-update="ignore"
phx-hook="SvelteHook"
></div>
Let’s break that down:
data-name
- the name of the component to mountdata-props
- JSON-encoded props passed from LiveViewdata-slots
- base64-encoded HTML for each slot (used for named and default slot content)phx-update="ignore"
- tells LiveView not to diff or patch this nodephx-hook="SvelteHook"
- attaches theSvelteHook
hookid
- id of the element required by LiveView because ofphx-hook
andphx-ignore
The SvelteHook
is responsible for reading these attributes, decoding the slot content and rendering it to HTML, using the correct Svelte component based on name provided in data-name
, and using Svelte to render it inside the div.
How LiveSvelte knows which component to render? It's simple - the package uses the file path to determine the component name. So if your component is nested inside folders, for example assets/svelte/components/Button.svelte
, you would use name="components/Button"
in your LiveView. Under the hood, SvelteHook
creates a look up object with all available components. If the component was already rendered on the server (via SSR), it gets hydrated. Otherwise, it’s rendered entirely on the client.
All of these packages support Server-Side Rendering out of the box - it’s enabled by default. Just make sure node
is installed on your production server, as it’s required for rendering components on the server.
Svelte
My personal favorite, and arguably the most natural pairing with LiveView. Svelte works seamlessly with esbuild
, making it the most straightforward integration of all the packages we’ll look at today.
Setup
Add
{:live_svelte, "~> 0.15.0"}
todeps
inmix.exs
, then runmix deps.get
.Run
mix live_svelte.setup
- this generates config and sets up the build script.Import
LiveSvelte
in yourhtml_helpers/0
(lib/your_app_web.ex
).Replace aliases in
mix.exs
forsetup
andassets.deploy
.setup: ["deps.get", "ecto.setup", "cmd --cd assets npm install"], "assets.deploy": ["tailwind <app_name> --minify", "cmd --cd assets node build.js --deploy", "phx.digest"]
If using Tailwind, add
"./svelte/**/*.svelte"
totailwind.config.js
undercontent
so your styles get picked up.Remove old
esbuild
andtailwind
configs fromconfig.exs
and deps frommix.exs
- LiveSvelte uses customesbuild
config and it handles Tailwind.Let’s install a component library (optional)
cd assets && npm i carbon-components-svelte
Components
Let’s look at a practical example using a Svelte component with LiveSvelte. We'll build a simple counter component that can increment and decrement by a derived local value, with all the goodness of Svelte and pushing events back to LiveView. All of your Svelte components should be placed in the assets/svelte
directory.
// assets/svelte/Counter.svelte
<script>
import { Button } from "carbon-components-svelte";
let { count = 0, live } = $props();
let base = $state(1);
let multi = $state(1);
let by = $derived(base * multi);
function increment() {
live.pushEvent("inc", { by });
}
</script>
<p>{count}</p>
<Button onclick={increment}>+{by}</Button>
<Button phx-click="dec" phx-value-by={by}>-{by}</Button>
<input type="range" min="1" max="10" bind:value={base} />
<input type="range" min="1" max="10" bind:value={multi} />
As you can see, the live
object gives us access to LiveView interactions like pushEvent
, handleEvent
, and file upload
. The live
object is available in props only if you provide socket
to the component in the heex
template. You’re not limited to this approach either - traditional phx-
bindings still work just fine, and you can freely mix both styles based on your needs.
Note: When using component libraries with
phx-
bindings, keep in mind that components may strip away any non-standard HTML attributes. This means attributes likephx-click
orphx-value-*
might be ignored unless the component explicitly forwards them.
# lib/example_web/live/test_one_live.ex
defmodule ExampleWeb.TestOneLive do
use ExampleWeb, :live_view
@impl true
def render(assigns) do
~H"""
<.svelte name="Counter" socket={@socket} props={%{count: @count}} />
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, count: 0)}
end
@impl true
def handle_event("inc", %{"by" => by}, socket) do
by = if is_binary(by), do: String.to_integer(by), else: by
{:noreply, update(socket, :count, &(&1 + by))}
end
def handle_event("dec", %{"by" => by}, socket) do
by = if is_binary(by), do: String.to_integer(by), else: by
{:noreply, update(socket, :count, &(&1 - by))}
end
end
To use a Svelte component, you’ll use the <.svelte />
component and provide it with a few attributes:
name
– the name of the Svelte component (e.g. forCounter.svelte
, you'd usename="Counter"
),socket
– the LiveView socket, required if you want access to thelive
object inside the component,props
– a map of props that will be forwarded into the component via$props()
,ssr
- when provided withfalse
value disables SSR.
Slots
Let’s take a look at how to use slots in a Svelte component rendered via LiveSvelte.
// assets/svelte/TodoForm.svelte
<script>
let { children, header } = $props();
</script>
<section>
{@render header?.()}
<div>{@render children?.()}<div/>
</section>
Slots work just like in LiveView. You can define named slots (like :header
) and the default inner slot (children
), and render them inside your Svelte component with {@render slot()}
.
Here’s how it looks from the LiveView side:
# lib/example_web/live/test_two_live.ex
defmodule ExampleWeb.TestTwoLive do
use ExampleWeb, :live_view
@impl true
def render(assigns) do
~H"""
<.svelte name="TodoForm" socket={@socket}>
<:header><h2>Create Task</h2></:header>
<.form id="task-form" for={@form} phx-change="validate" phx-submit="create">
<.input field={@form[:name]} label="Name" required />
<.button type="submit">Create</.button>
</.form>
</.svelte>
"""
end
# *rest of the logic*
end
One current limitation: you can't slot other Svelte components. Everything passed into a slot must be plain HTML or LiveView components.
Sigil
But what if you want to write Svelte components directly inside your LiveView module? That’s possible using the ~V
sigil.
# lib/example_web/live/test_three_live.ex
defmodule ExampleWeb.TestThreeLive do
use ExampleWeb, :live_view
@impl true
def render(assigns) do
~V"""
<script>
const { live } = $props();
</script>
<button onclick={() => live.pushEvent("inc", { by: 10 })}>Click</button>
"""
end
# *rest of the logic*
end
The ~V
sigil compiles the inline Svelte code into a component under assets/svelte/_build
. This means you must use relative imports starting with ..
when referencing other Svelte components.
// assets/svelte/_build/Elixir.ExampleWeb.TestThreeLive.svelte
<script>
const { live } = $props();
</script>
<button onclick={() => live.pushEvent("inc", { by: 10 })}>Click</button>
Note: Be sure to add
/assets/svelte/_build/
to your.gitignore
, as these files are generated and shouldn't be committed.
React
React is the most widely used frontend framework. Loved by some, hated by others, but impossible to ignore.
Setup
The setup is slightly more complex, as it requires vite
. In development, a running vite
server is used to render server side rendered components on the fly. But don’t worry you don’t need to run the vite
server manually (look at the 4th step 😉).
Add required dependencies to
mix.exs
and runmix deps.get
{:live_react, "~> 1.0.0"}, {:nodejs, "~> 3.1.2"} # for SSR in production
Run
mix live_react.setup
- this generates server entry point file and configs forvite
,typescript
andpostcss
.Update
config/dev.exs
withconfig :live_react, vite_host: "http://localhost:5173", ssr_module: LiveReact.SSR.ViteJS, ssr: true
Update watchers in
config/dev.exs
config :example, ExampleWeb.Endpoint, # ... watchers: [ npm: ["run", "dev", cd: Path.expand("../assets", __DIR__)] ]
Update
live_reload
inconfig/dev.exs
- addnotify
section and removelive|components
from patternsconfig :example, ExampleWeb.Endpoint, live_reload: [ notify: [ live_view: [ ~r"lib/example_web/core_components.ex$", ~r"lib/example_web/(live|components)/.*(ex|heex)$" ] ], patterns: [ ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$", ~r"lib/example_web/controllers/.*(ex|heex)$" ] ]
Update
config/prod.exs
withconfig :live_react, ssr_module: LiveReact.SSR.NodeJS, ssr: true
Import
LiveReact
in yourhtml_helpers/0
(lib/yourapp_web.ex
).Update
/assets/js/app.js
with// *rest of the code* import topbar from "topbar" // instead of ../vendor/topbar import { getHooks } from "live_react"; import components from "../react-components"; import "../css/app.css" // the css file is handled by vite // *rest of the code* let liveSocket = new LiveSocket("/live", Socket, { hooks: { ...getHooks(components) }, // hooks :) longPollFallbackMs: 2500, params: { _csrf_token: csrfToken }, }); // *rest of the code*
Update aliases in
mix.exs
forsetup
andassets.deploy
."assets.setup": ["cmd --cd assets npm install"], "assets.build": [ "cmd --cd assets npm run build", "cmd --cd assets npm run build-server" ], "assets.deploy": [ "cmd --cd assets npm run build", "cmd --cd assets npm run build-server", "phx.digest" ]
If using Tailwind, add
"./react-components/**/*.(jsx|tsx)"
totailwind.config.js
undercontent
so your styles get picked up.Remove old
esbuild
andtailwind
configs fromconfig.exs
and deps frommix.exs
- LiveReact uses customvite
config and it handles TailwindUpdate
root.html.heex
,<LiveReact.Reload.vite_assets assets={["/js/app.js", "/css/app.css"]}> <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} /> <script type="module" phx-track-static type="text/javascript" src={~p"/assets/app.js"}> </script> </LiveReact.Reload.vite_assets>
Update
application.ex
(required for SSR in production)children = [ {NodeJS.Supervisor, [path: LiveReact.SSR.NodeJS.server_path(), pool_size: 4]}, # rest of the code ]
Components
We will revisit the same example as in Svelte: a simple counter component that increments and decrements based on a derived local value. This time, however, your components should be placed under the assets/react-components
directory.
// assets/react-components/Counter.jsx
import React, { useMemo, useState } from "react";
export function Counter({ count = 0, pushEvent }) {
const [base, setBase] = useState(1);
const [multi, setMulti] = useState(1);
const by = useMemo(() => base * multi, [base, multi]);
const increment = () => {
pushEvent("inc", { by });
};
return (
<>
<p>{count}</p>
<button onClick={increment}>+{by}</button>
<button phx-click="dec" phx-value-by={by}>-{by}</button>
<input
type="range"
min="1"
max="10"
value={base}
onChange={(e) => setBase(e.target.valueAsNumber)}
/>
<input
type="range"
min="1"
max="10"
value={multi}
onChange={(e) => setMulti(e.target.valueAsNumber)}
/>
</>
);
}
In Svelte, we accessed the live
object directly from the props. In React, however, all the values from the live
object are spread across the props. This means you can use destructuring to extract only the specific values you need.
// assets/react-components/index.js
import { Counter } from "./Counter";
export default { Counter };
This time, we need to create the lookup object for components ourselves. While it adds an extra step, it gives us the flexibility to expose only the components we want and assign them the names we prefer.
# lib/example_web/live/test_one_live.ex
defmodule ExampleWeb.TestOneLive do
use ExampleWeb, :live_view
@impl true
def render(assigns) do
~H"""
<.react name="Counter" socket={@socket} count={@count} />
"""
end
*rest of the logic*
end
To use a React component, you’ll use the <.react />
component and provide it with a few attributes:
name
- the name of the component in the look up object,socket
- the LiveView socket, required if you want access to thelive
values inside the component,ssr
- when provided withfalse
value disables SSR- props are passed directly without any wrapper, so you’ll need to assign them as attributes to the component. This can be a bit inconvenient, especially when our data includes a field named "name".
Slots
Unfortunately, React only supports the default inner slot.
// assets/react-components/TodoForm.jsx
export function TodoForm({ children }) {
return <div>{children}</div>;
}
Note: Remember to add new components to the look up object
# lib/example_web/live/test_two_live.ex
defmodule ExampleWeb.TestTwoLive do
use ExampleWeb, :live_view
@impl true
def render(assigns) do
~H"""
<.react name="TodoForm" socket={@socket}>
<h2>Create Task</h2>
<.form id="task-form" for={@form} phx-change="validate" phx-submit="create">
<.input field={@form[:name]} label="Name" required />
<.button type="submit">Create</.button>
</.form>
</.react>
"""
end
# *rest of the logic*
end
Vue
I’m not deeply familiar with Vue, but after a quick dive, I was pleasantly surprised. The syntax is clean and approachable, even for someone new to the framework. What really stands out, though, is the ecosystem - well-documented, mature, and full of high-quality libraries that can slot right into your project with minimal hassle.
Setup
The setup for LiveVue is nearly identical to LiveReact, it also relies on vite
for building and serving components.
Add required dependencies to
mix.exs
and runmix deps.get
{:live_vue, "~> 1.0.0"}, {:nodejs, "~> 3.1.2"} # for SSR in production
Run
mix live_vue.setup
- this generates server entry point file and configs forvite
,typescript
andpostcss
.Update
config/dev.exs
withconfig :live_react, vite_host: "http://localhost:5173", ssr_module: LiveVue.SSR.ViteJS, ssr: true
Update watchers in
config/dev.exs
config :example, ExampleWeb.Endpoint, # ... watchers: [ npm: ["--silent", "run", "dev", cd: Path.expand("../assets", __DIR__)] ]
Update
live_reload
inconfig/dev.exs
config :example, ExampleWeb.Endpoint, live_reload: [ notify: [ live_view: [ ~r"lib/example_web/core_components.ex$", ~r"lib/example_web/(live|components)/.*(ex|heex)$" ] ], patterns: [ ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$", ~r"lib/example_web/controllers/.*(ex|heex)$" ] ]
Update
config/prod.exs
withconfig :live_react, ssr_module: LiveVue.SSR.NodeJS, ssr: true
Use
LiveVue
in yourhtml_helpers/0
(lib/yourapp_web.ex
).Update
/assets/js/app.js
with// *rest of the code* import topbar from "topbar" // instead of ../vendor/topbar import { getHooks } from "live_vue"; import liveVueApp from "../vue"; import "../css/app.css" // the css file is handled by vite // *rest of the code* let liveSocket = new LiveSocket("/live", Socket, { hooks: { ...getHooks(liveVueApp) }, // hooks :) longPollFallbackMs: 2500, params: { _csrf_token: csrfToken }, }); // *rest of the code*
Update aliases in
mix.exs
forsetup
andassets.deploy
.setup: ["deps.get", "assets.setup", "assets.build"], "assets.setup": ["cmd --cd assets npm install"], "assets.build": [ "cmd --cd assets npm run build", "cmd --cd assets npm run build-server" ], "assets.deploy": [ "cmd --cd assets npm run build", "cmd --cd assets npm run build-server", "phx.digest" ]
If using Tailwind, add
"./vue/**/*.vue"
and../lib/**/*.vue
totailwind.config.js
undercontent
so your styles get picked up.Remove old
esbuild
andtailwind
configs fromconfig.exs
and deps frommix.exs
- LiveVue uses customvite
config and it handles TailwindUpdate
root.html.heex
,<LiveVue.Reload.vite_assets assets={["/js/app.js", "/css/app.css"]}> <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} /> <script type="module" phx-track-static type="text/javascript" src={~p"/assets/app.js"}> </script> </LiveVue.Reload.vite_assets>
Update application (required for SSR in production)
children = [ {NodeJS.Supervisor, [path: LiveVue.SSR.NodeJS.server_path(), pool_size: 4]}, # *rest of the code* ]
Components
Once again, we’ll use the same example, a simple counter with a derived local value. Place your Vue components in ./assets/vue
.
// assets/vue/Counter.vue
<script setup lang="ts">
import { ref, computed } from "vue";
import { useLiveVue } from "live_vue";
const live = useLiveVue();
const props = defineProps<{ count: number }>();
const emit = defineEmits<{ inc: [{ by: number }] }>();
const base = ref<string>("1");
const multi = ref<string>("1");
const by = computed(() => parseInt(base.value) * parseInt(multi.value));
</script>
<template>
<p>{{ props.count }}</p>
<button @click="emit('inc', { by })">+{{ by }}</button>
<button @click="live.pushEvent('dec', { by })">-{{ by }}</button>
<input type="range" min="1" max="10" v-model="base" />
<input type="range" min="1" max="10" v-model="multi" />
</template>
In Vue, to access the live
object, you need to use the useLiveVue
hook from "live_vue"
. This function returns the live
object you can use to push events or handle uploads.
# lib/example_web/live/test_one_live.ex
defmodule ExampleWeb.TestOneLive do
use ExampleWeb, :live_view
@impl true
def render(assigns) do
~H"""
<.vue v-component="Counter" v-socket={@socket} v-on:inc={JS.push("inc")} count={@count} />
"""
end
# *rest of logic*
end
To use a Vue component, you’ll use the <.vue />
component and provide it with a few attributes:
v-component
- the name of the component,v-socket
- the LiveView socket, required in LiveViews,v-on:event
- event handler to invoke in your Vue component, has to beJS
module. LiveVue under the hood usesliveSocket.execJS/2
to execute the event. These events are attached as emit handles to Vue components,v-ssr
- when provided withfalse
value disables SSR- props are passed directly without any wrapper, so you’ll need to assign them as attributes to the component.
Slots
Let’s take a look at how to use slots in a Vue component rendered via LiveVue.
// assets/vue/TodoForm.vue
<script setup lang="ts"></script>
<template>
<section>
<slot name="header"></slot>
<div><slot></slot></div>
</section>
</template>
In Vue, you define and render slots using the <slot />
component. On the Elixir side, slots work just like they do in regular LiveView components.
# lib/example_web/live/test_two_live.ex
defmodule ExampleWeb.TestTwoLive do
use ExampleWeb, :live_view
@impl true
def render(assigns) do
~H"""
<.vue v-component="TodoForm" v-socket={@socket}>
<:header><h2>Create Task</h2></:header>
<.form id="task-form" for={@form} phx-change="validate" phx-submit="create">
<.input field={@form[:name]} label="Name" required />
<.button type="submit">Create</.button>
</.form>
</.vue>
"""
end
# *rest of the logic*
end
Sigil
LiveVue, like LiveSvelte, also supports the ~V
sigil for writing Vue components directly within your LiveView modules.
# lib/example_web/live/test_three_live.ex
defmodule ExampleWeb.TestThreeLive do
use ExampleWeb, :live_view
@impl true
def render(assigns) do
~V"""
<script setup lang="ts">
import { useLiveVue } from "live_vue";
const live = useLiveVue();
const props = defineProps<{ count: number }>();
</script>
<template>
<p>{{ props.count }}</p>
<button @click="live.pushEvent('inc', { by: 10 })">Click</button>
</template>
"""
end
# *rest of the logic*
end
Just like in LiveSvelte, the ~V
sigil compiles inline Vue code into a component under assets/vue/_build
. Because of that, any imports inside must use relative paths starting with ..
to reference other Vue components.
// assets/vue/_build/Elixir.ExampleWeb.TestThreeLive.vue
<script setup lang="ts">
const live = useLiveVue();
</script>
<template>
<button @click="live.pushEvent('inc', { by: 10 })">Click</button>
</template>
Vanilla with hooks
While all of this is possible without using any frameworks, there's a reason we rely on them. Updating values manually with JavaScript can be cumbersome, and with LiveView, we also have to manage phx-ignore
and id
attributes, which adds unnecessary complexity.
# lib/example_web/live/counter_live.ex
defmodule ExampleWeb.CounterLive do
use ExampleWeb, :live_view
@impl true
def render(assigns) do
~H"""
<div id="counter" phx-hook="Counter">
<.button id="inc-btn" phx-click="inc" phx-value-by="1" phx-update="ignore" class="grow">
+1
</.button>
<p>{@count}</p>
<.button id="dec-btn" phx-click="dec" phx-value-by="1" phx-update="ignore" class="grow">
-1
</.button>
<.input
id="base-input"
name="base"
label="Base"
type="range"
value="1"
min="1"
max="10"
/>
<.input
id="multi-input"
name="multi"
label="Multiplier"
type="range"
value="1"
min="1"
max="10"
/>
</div>
"""
end
# *rest of the logic*
end
// assets/js/app.js
const hooks = {};
hooks.Counter = {
mounted() {
const multiInput = this.el.querySelector("#multi-input");
const baseInput = this.el.querySelector("#base-input");
const incButton = this.el.querySelector("#inc-btn");
const decButton = this.el.querySelector("#dec-btn");
[multiInput, baseInput].forEach((input) => {
input?.addEventListener("input", () => {
const by = (multiInput?.value ?? 1) * (baseInput?.value ?? 1);
incButton.textContent = `+${by}`;
incButton.setAttribute("phx-value-by", by);
decButton.textContent = `-${by}`;
decButton.setAttribute("phx-value-by", by);
});
});
},
};
// *rest of the logic*
Streams
While LiveView streams aren't officially supported yet, we can still use them by wrapping the component in a <div>
with style="display: contents"
. This CSS property ensures the wrapper doesn't interfere with layout or DOM structure, it behaves as if the wrapper doesn't exist, allowing the stream to work as expected. Remember, since this approach isn’t officially supported, you might run into occasional issues.
defmodule ExampleWeb.StreamsLive do
use ExampleWeb, :live_view
@impl true
def render(assigns) do
~H"""
<div id="tasks" phx-update="stream">
<div
:for={{dom_id, task} <- @streams.tasks}
id={dom_id}
class="contents"
>
<.svelte
name="TaskItem"
socket={@socket}
props={%{
id: task.id,
name: task.name,
completed: task.completed
}}
/>
</div>
</div>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> stream_configure(:tasks, dom_id: &"task-#{&1.id}")
|> stream(:tasks, [%{id: 1, name: "Task 1", completed: true}])}
end
end
Uncertainties
Conclusion
Phoenix LiveView is already great for building dynamic web apps without much JavaScript. But sometimes, you need more interactivity - like animations or complex local state. LiveSvelte, LiveVue, and LiveReact make it easy to embed components from popular frontend frameworks directly into your LiveView templates. The LiveView ecosystem can still feel a bit immature at times, especially when it comes to component libraries. One big advantage of using these integrations is that you instantly gain access to the most of the ecosystem of your chosen frontend framework. Of course, it’s not all sunshine and butterflies. Adding another layer to your stack brings more complexity and higher resource usage. You’ll need to manage build tools like Vite, write more JavaScript, which some developers avoid like a plague and handle two sources of translations. Still, these tools offer a powerful middle ground: the productivity and real-time benefits of LiveView, combined with the flexibility of modern frontend frameworks.
Source (look at branches 🙂)