Svelte inside Phoenix LiveView with seamless end-to-end reactivity
Features • Resources • Demo • Installation • Usage • Deployment
For a full intro and demo check out the YouTube introduction
/examples/advanced_chat
/examples/breaking_news
Phoenix LiveView enables rich, real-time user experiences with server-rendered HTML. It works by communicating any state changes through a websocket and updating the DOM in realtime. You can get a really good user experience without ever needing to write any client side code.
LiveSvelte builds on top of Phoenix LiveView to allow for easy client side state management while still allowing for communication over the websocket.
For Server-Side Rendering (SSR) to work you need node
(version 19 or later) installed in your environment.
Make sure you have it installed in production too. You might be using node
in the build step, but it might actually not be installed in your production environment.
You can make sure you have node
installed by running node --version
in your project directory.
If you don't want SSR, you can disable it by not setting NodeJS.Supervisor
in application.ex
. More on that in the SSR section of this document.
If you're updating from an older version, make sure to check the CHANGELOG.md
for breaking changes.
live_svelte
to your list of dependencies of your Phoenix app in mix.exs
:defp deps do
[
{:live_svelte, "~> 0.14.1"}
]
end
setup
and assets.deploy
aliases in mix.exs
:defp aliases do
[
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"]
]
end
Note: tailwind <app_name> --minify
is only required in the assets.deploy
alias if you're using Tailwind. If you are not using Tailwind, you can remove it from the list.
mix deps.get
mix live_svelte.setup
import LiveSvelte
in html_helpers/0
inside /lib/<app_name>_web.ex
like so:# /lib/<app_name>_web.ex
defp html_helpers do
quote do
# ...
import LiveSvelte # <-- Add this line
# ...
end
end
"./svelte/**/*.svelte"
to content
in the tailwind.config.js
file...
content: [
...
"./svelte/**/*.svelte"
],
...
esbuild
configuration from config/config.exs
and remove the dependency from the deps
function in your mix.exs
, and you are done!Phoenix's default configuration of esbuild (via the Elixir wrapper) does not allow you to use esbuild plugins. The standard Elixir esbuild
package works great for simple projects with Phoenix hooks, but to use LiveSvelte we need a more complex setup.
To use plugins, Phoenix recommends replacing the default build system with a build script. So, in setup, we go from using the standard esbuild
package to using esbuild
directly as a node_module
.
As a result, you'll notice some related changes:
/assets
./lib
.esbuild
watcher, as we created a new watcher that does the same thing.The setup process commented out some lines of code, like configuration in dev.exs
. It's safe to delete commented-out code if you desire.
Svelte components need to go into the assets/svelte
directory
Attributes:
name
: Specify the Svelte componentprops
(Optional): Provide the props
you want to use that should be reactive as a map to the props fieldclass
(Optional): Provide class
to set the class attribute on the root svelte elementssr
(Optional): Set ssr
to false
to disable server-side renderinge.g. If your component is named assets/svelte/Example.svelte
:
def render(assigns) do
~H"""
<.svelte name="Example" props={%{number: @number}} socket={@socket} />
"""
end
If your component is in a directory, for example assets/svelte/some-directory/SomeComponent.svelte
you need to include the directory in your name: some-directory/SomeComponent
.
There is also an Elixir macro which checks your assets/svelte
folder for any Svelte components, and injects local function def
s for those components into the calling module.
This allows for an alternative, more JSX-like authoring experience inside Liveviews.
e.g. in the below example, a Svelte component called Example
is available to be called inside the Liveview template:
use LiveSvelte.Components
def render(assigns) do
~H"""
<.Example number={@number} socket={@socket} />
"""
end
Examples can be found in the /examples
and /example_project
directories.
Most of the /example_project
examples are visible in the YouTube demo video.
I recommend cloning live_svelte
and running the example project in /example_project
by running the following commands:
git clone https://github.com/woutdp/live_svelte.git
mix assets.build
cd ./live_svelte/example_project
npm install --prefix assets
mix deps.get
mix phx.server
Server should be running on localhost:4000
If you have examples you want to add, feel free to create a PR, I'd be happy to add them.
<script>
// The number prop is reactive,
// this means if the server assigns the number, it will update in the frontend
export let number = 1
// live contains all exported LiveView methods available to the frontend
export let live
function increase() {
// This pushes the event over the websocket
// The last parameter is optional. It's a callback for when the event is finished.
// You could for example set a loading state until the event is finished if it takes a longer time.
live.pushEvent("set_number", {number: number + 1}, () => {})
// Note that we actually never set the number in the frontend!
// We ONLY push the event to the server.
// This is the E2E reactivity in action!
// The number will automatically be updated through the LiveView websocket
}
function decrease() {
live.pushEvent("set_number", {number: number - 1}, () => {})
}
</script>
<p>The number is {number}</p>
<button on:click={increase}>+</button>
<button on:click={decrease}>-</button>
Note: that here we use the pushEvent
function, but you could also use phx-click
and phx-value-number
if you wanted.
The following methods are available on live
:
pushEvent
pushEventTo
handleEvent
removeHandleEvent
upload
uploadTo
These need to be run on the client, they can't be run in SSR. Either make sure they're called on an action (e.g. clicking a button) or wrap them with onMount
.
More about this in the LiveView documentation on js-interop
# `/lib/app_web/live/live_svelte.ex`
defmodule AppWeb.SvelteLive do
use AppWeb, :live_view
def render(assigns) do
~H"""
<.svelte name="Example" props={%{number: @number}} socket={@socket} />
"""
end
def handle_event("set_number", %{"number" => number}, socket) do
{:noreply, assign(socket, :number, number)}
end
def mount(_params, _session, socket) do
{:ok, assign(socket, :number, 5)}
end
end
# `/lib/app_web/router.ex`
import Phoenix.LiveView.Router
scope "/", AppWeb do
...
live "/svelte", SvelteLive
...
end
We can go one step further and use LiveSvelte as an alternative to the standard LiveView DSL. This idea is inspired by Surface UI.
Take a look at the following example:
defmodule ExampleWeb.LiveSigil do
use ExampleWeb, :live_view
def render(assigns) do
~V"""
<script>
export let number = 5
let other = 1
$: combined = other + number
</script>
<p>This is number: {number}</p>
<p>This is other: {other}</p>
<p>This is other + number: {combined}</p>
<button phx-click="increment">Increment</button>
<button on:click={() => other += 1}>Increment</button>
"""
end
def mount(_params, _session, socket) do
{:ok, assign(socket, :number, 1)}
end
def handle_event("increment", _value, socket) do
{:noreply, assign(socket, :number, socket.assigns.number + 1)}
end
end
Use the ~V
sigil instead of ~H
and your LiveView will be Svelte instead of an HEEx template.
html_helpers/0
, add import LiveSvelte
inside the live_view
function in your project, this can be found in /lib/<app_name>_web.ex
:def live_view do
quote do
use Phoenix.LiveView,
layout: {ExampleWeb.Layouts, :app}
import LiveSvelte
unquote(html_helpers())
end
end
.gitignore
. The sigil will create Svelte files that are then picked up by esbuild
, these files don't need to be included in your git repo:# Ignore automatically generated Svelte files by the ~V sigil
/assets/svelte/_build/
To enable syntax highlighting in Neovim with Treesitter, create the following file:
~/.config/nvim/after/queries/elixir/injections.scm
; extends
; Svelte
(sigil
(sigil_name) @_sigil_name
(quoted_content) @injection.content
(#eq? @_sigil_name "V")
(#set! injection.language "svelte"))
For Neovim Treesitter version below v0.9:
; extends
; Svelte
(sigil
(sigil_name) @_sigil_name
(quoted_content) @svelte
(#eq? @_sigil_name "V"))
Also make sure Svelte and Elixir is installed in Treesitter.
Options can be passed in the mount by setting svelte_opts
, check the following example:
def mount(_params, _session, socket) do
{:ok, assign(socket, some_value: 1, svelte_opts: %{ssr: false, class: "example-class"})}
end
Inside Svelte you can define Live Navigation links. These links navigate from one LiveView to the other without refreshing the page.
For example this can be useful when you have a Svelte store and you want this store state to remain during navigation. Example of Svelte store usage can be found in /examples/store
.
push_navigate
<a href="/your-liveview-path" data-phx-link="redirect" data-phx-link-state="push">Redirect</a>
push_patch
<a href="/your-liveview-path" data-phx-link="patch" data-phx-link-state="push">Patch</a>
LiveView allows for a bunch of interoperability which you can read more about here: https://hexdocs.pm/phoenix_live_view/js-interop.html
To use the preprocessor, install the desired preprocessor.
e.g. Typescript
cd assets && npm install --save-dev typescript
If you're unfamiliar with SSR (Server-Side Rendering), it is a feature of Svelte to... render Svelte on the server. This means on first page load you get to see HTML instead of a blank page. Immediately after the first page load the page is 'hydrated', which is a fancy word for adding reactivity to your component. This happens in the background, you don't notice this step happening.
The way LiveSvelte updates itself through LiveView is by letting Svelte handle all the HTML edits. Usually LiveView would edit the HTML by passing messages through the websocket. In our case we only pass the data we provided in the props attribute to Svelte through the websocket. No HTML is being touched by LiveView, Svelte takes care of that.
Like mentioned, without SSR you'd see a brief flash of un-rendered content. Sometimes you can get away with not rendering Svelte on the server, for example when your Svelte component doesn't do any rendering on first page load and needs to be manually toggled for visibility by the user. Or when it is a component that has no visual component to it like tracking your mouse cursor and sending it back to the server.
In theses cases you can turn off SSR.
SSR is enabled by default when you install LiveSvelte. If you don't want to use Server-Side Rendering for Svelte, you can disable it in the following ways:
If you don't want to use SSR on any component you can disable it globally.
There are 2 ways of doing this
NodeJS
supervisor in the application.ex
file
orssr: false
to the live_svelte
config in your config.exs
file like so:config :live_svelte,
ssr: false
To disable SSR on a specific component, set the ssr
property to false. Like so:
<.svelte name="Example" ssr={false} />
LiveSvelte has support for live_json.
By default, LiveSvelte sends your entire json object over the wire through LiveView. This can be expensive if your json object is big and changes frequently.
live_json
on the other hand allows you to only send a diff of the json to Svelte. This is very useful the bigger your json objects get.
Counterintuitively, you don't always want to use live_json
. Sometimes it's cheaper to send your entire object again. Although diffs are small, they do add a little bit of data to your json. So if your json is relatively small, I'd recommend not using live_json
, but it's something to experiment with for your use-case.
Install live_json
Use live_json
in your project with LiveSvelte. For example:
def render(assigns) do
~H"""
<.svelte name="Component" live_json_props={%{my_prop: @ljmy_prop}} socket={@socket} />
"""
end
def mount(_, _, socket) do
# Get `my_big_json_object` somehow
{:ok, LiveJson.initialize("my_prop", my_big_json_object)}
end
def handle_info(%Broadcast{event: "update", payload: my_big_json_object}, socket) do
{:noreply, LiveJson.push_patch(socket, "my_prop", my_big_json_object)}
end
You can find an example here.
We use Jason to serialize any data you pass in the props so it can be handled by Javascript. Jason doesn't know how to handle structs by default, so you need to define it yourself.
For example, if you have a regular struct like this:
defmodule User do
defstruct name: "John", age: 27, address: "Main St"
end
You must define @derive
defmodule User do
@derive Jason.Encoder
defstruct name: "John", age: 27, address: "Main St"
end
Be careful though, as you might accidentally leak certain fields you don't want the client to access, you can include which fields to serialize:
defmodule User do
@derive {Jason.Encoder, only: [:name, :age]}
defstruct name: "John", age: 27, address: "Main St"
end
In ecto's case it's important to also omit the __meta__
field as it's not serializable.
Check out the following example:
defmodule Example.Planets.Planet do
use Ecto.Schema
import Ecto.Changeset
@derive {Jason.Encoder, except: [:__meta__]}
schema "planets" do
field :diameter, :integer
field :mass, :integer
field :name, :string
timestamps()
end
...
end
More documentation on the topic:
Slot interoperability is still experimental, so use with caution!
Svelte doesn't have an official way of setting the slot on mounting the Svelte object or updating it on subsequent changes, unlike props. This makes using slots from within Liveview on a Svelte component fragile.
The server side rendered initial Svelte rendering does have support for slots so that should work as expected.
Slots may eventually reach a state where it is stable, any help in getting there is appreciated. If you know a lot about the internals of Svelte your help may be invaluable here!
Any bugs related to this are welcome to be logged, PR's are especially welcome!
With LiveView, it's easy to keep things secret. Let's say you have a conditional that only renders when something is true
, in LiveView there's no way to know what that conditional will show until it is shown, that's because the HTML is sent over the wire.
With LiveSvelte, we're dealing with JSON being sent to Svelte, which in turn takes that JSON data and conditionally renders something, even if we don't set the conditional to true
, the Svelte code will contain code on what to show when the conditional turns true
.
In a lot of scenarios this is not an issue, but it can be and is something you should be aware of.
You can use /example_project
as a way to test live_svelte
locally.
You can also use your own project.
Clone live_svelte
to the parent directory of the project you want to test it in.
Inside mix.exs
{:live_svelte, path: "../live_svelte"},
Inside assets/package.json
"live_svelte": "file:../../live_svelte",
Make the changes in /assets/js
and run:
mix assets.build
Or run the watcher:
mix assets.build --watch
mix assets.build
README.md
package.json
mix.exs
Run:
mix hex.publish
Deploying a LiveSvelte app is the same as deploying a regular Phoenix app, except that you will need to ensure that nodejs
(version 19 or later) is installed in your production environment.
The below guide shows how to deploy a LiveSvelte app to Fly.io, but similar steps can be taken to deploy to other hosting providers. You can find more information on how to deploy a Phoenix app here.
The following steps are needed to deploy to Fly.io. This guide assumes that you'll be using Fly Postgres as your database. Further guidance on how to deploy to Fly.io can be found here.
Dockerfile
:mix phx.gen.release --docker
Dockerfile
to install curl
, which is used to install nodejs
(version 19 or greater), and also add a step to install our npm
dependencies:# ./Dockerfile
...
# install build dependencies
- RUN apt-get update -y && apt-get install -y build-essential git \
+ RUN apt-get update -y && apt-get install -y build-essential git curl \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
+ # install nodejs for build stage
+ RUN curl -fsSL https://deb.nodesource.com/setup_19.x | bash - && apt-get install -y nodejs
...
COPY assets assets
+ # install all npm packages in assets directory
+ WORKDIR /app/assets
+ RUN npm install
+ # change back to build dir
+ WORKDIR /app
...
# start a new build stage so that the final image will only contain
# the compiled release and other runtime necessities
FROM ${RUNNER_IMAGE}
RUN apt-get update -y && \
- apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
+ apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates curl \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
+ # install nodejs for production environment
+ RUN curl -fsSL https://deb.nodesource.com/setup_19.x | bash - && apt-get install -y nodejs
...
Note: nodejs
is installed BOTH in the build stage and in the final image. This is because we need nodejs
to install our npm
dependencies and also need it when running our app.
fly launch
y
:? Do you want to tweak these settings before proceeding? (y/N) y
This will launch a new window where you can tweak your launch settings. In the database section, choose Fly Postgres
and enter a name for your database. You may also want to change your database to the development configuration to avoid extra costs. You can leave the rest of the settings as-is unless you want to change them.
Deployment will continue once you hit confirm.
fly apps open
LiveSvelte currently only supports Svelte 4. Svelte 5 support is in the works, keep track of it in Issue 124.
Using LiveSvelte in a public project? Let me know and I'll add it to this list!