End-to-end reactivity for Phoenix LiveView and Vue
Vue inside Phoenix LiveView with seamless end-to-end reactivity.
After installation, you can use Vue components in the same way as you'd use functional LiveView components. You can even handle Vue events with JS
hooks! All the phx-click
, phx-change
attributes works inside Vue components as well.
<script setup lang="ts">
import {ref} from "vue"
const props = defineProps<{count: number}>()
const emit = defineEmits<{inc: [{value: number}]}>()
const diff = ref<string>("1")
</script>
<template>
Current count
<div class="text-2xl text-bold">{{ props.count }}</div>
<label class="block mt-8">Diff: </label>
<input v-model="diff" class="my-4" type="range" min="1" max="10" />
<button
@click="emit('inc', {value: parseInt(diff)})"
class="bg-black text-white rounded p-2"
>
Increase counter by {{ diff }}
</button>
</template>
defmodule LiveVueExamplesWeb.LiveCounter do
use LiveVueExamplesWeb, :live_view
def render(assigns) do
~H"""
<.vue
count={@count}
v-component="Counter"
v-socket={@socket}
v-on:inc={JS.push("inc")}
/>
"""
end
def mount(_params, _session, socket) do
{:ok, assign(socket, :counter, 0)}
end
def handle_event("inc", %{"value" => diff}, socket) do
{:noreply, update(socket, :count, &(&1 + diff))}
end
end
This project is heavily inspired by ✨ LiveSvelte ✨. Both projects try to solve the same problem. LiveVue was started as a fork fo LiveSvelte with adjusted ESbuild settings, and evolved to use Vite and a slightly different syntax. I strongly believe more options are always better, and since I love Vue and it's ecosystem I've decided to give it a go 😉
You can read more about differences between Vue and Svelte in FAQ.
Phoenix Live View makes it possible to create rich, interactive web apps without writing JS.
But once you'll need to do anything even slightly complex on the client-side, you'll end up writing lots of imperative, hard-to-maintain hooks.
LiveVue allows to create hybrid apps, where part of the session state is on the server and part on the client.
LiveVue replaces esbuild
with Vite for both client side code and SSR to achieve an amazing development experience. In production, we'll use elixir-nodejs for SSR. If you don't need SSR, you can disable it with one line of code. TypeScript will be supported as well.
Please install node
😉
Add live_vue
to your list of dependencies of your Phoenix app in mix.exs
and run mix deps.get
defp deps do
[
{:live_vue, "~> 0.3"}
]
end
config/dev.exs
fileconfig :live_vue,
vite_host: "http://localhost:5173",
ssr_module: LiveVue.SSR.ViteJS,
# if you want to disable SSR by default, make it false
ssr: true
config/prod.exs
fileconfig :live_vue,
ssr_module: LiveVue.SSR.NodeJS,
ssr: true
html_helpers
in lib/my_app_web.ex
defp html_helpers do
quote do
# ...
# Add support to Vue components
use LiveVue
# Generate component for each vue file, so you can omit v-component="name".
# You can configure path to your components by using optional :vue_root param
use LiveVue.Components, vue_root: ["./assets/vue", "./lib/my_app_web"]
end
end
cd assets
# vite
npm install -D vite @vitejs/plugin-vue
# tailwind
npm install -D tailwindcss autoprefixer postcss @tailwindcss/forms
# typescript
npm install -D typescript vue-tsc
# runtime dependencies
npm install --save vue topbar ../deps/live_vue ../deps/phoenix ../deps/phoenix_html ../deps/phoenix_live_view
# remove topbar from vendor, since we'll use it from node_modules
rm vendor/topbar.js
Next, let's copy SSR entrypoint, vite config and typescript config from live_vue
. If you have any of these files, they'll be skipped so you could update them on your own.
mkdir vue
for SOURCE in $(find ../deps/live_vue/assets/copy -type f); do
DEST=${SOURCE#../deps/live_vue/assets/copy/}
if [ -e "$DEST" ]; then
echo "SKIPPED $SOURCE -> $DEST. Please update manually"
else
echo "COPIED $SOURCE -> $DEST"
cp $SOURCE $DEST
fi
done
Now we just have to adjust app.js hooks and tailwind config to include vue
files:
// app.js
import topbar from "topbar" // instead of ../vendor/topbar
// ...
import {getHooks} from "live_vue"
import components from "../vue"
import "../css/app.css"
let liveSocket = new LiveSocket("/live", Socket, {
// ...
hooks: getHooks(components),
})
// tailwind.config.js
module.exports = {
content: [
// ...
"./vue/**/*.vue",
"../lib/**/*.vue", // include Vue files
],
}
and lastly let's add dev and build scripts to package.json
{
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host -l warn",
"build": "vue-tsc && vite build",
"build-server": "vue-tsc && vite build --ssr js/server.js --out-dir ../priv/vue --minify esbuild --ssrManifest && echo '{\"type\": \"module\" } ' > ../priv/vue/package.json"
}
}
<!-- Wrap existing CSS and JS in LiveVue.Reload.vite_assets component,
pass paths to original files in assets -->
<LiveVue.Reload.vite_assets assets={["/js/app.js", "/css/app.css"]}>
<link phx-track-static rel="stylesheet" href="/assets/app.css" />
<script type="module" phx-track-static type="text/javascript" src="/assets/app.js">
</script>
</LiveVue.Reload.vite_assets>
mix.exs
aliases and get rid of tailwind
and esbuild
packagesdefp aliases do
[
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"
]
]
end
defp deps do
[
# remove these lines, we don't need esbuild or tailwind here anymore
# {:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
# {:tailwind, "~> 0.2", runtime: Mix.env() == :dev},
]
end
Remove esbuild and tailwind config from config/config.exs
Update watchers in config/dev.exs
to look like this
config :my_app, MyAppWeb.Endpoint,
# ...
watchers: [
npm: ["run", "dev", cd: Path.expand("../assets", __DIR__)]
]
LiveVue.SSR.NodeJS
(recommended for production), you have to add this entry to your application.ex
supervision tree:children = [
{NodeJS.Supervisor, [path: LiveVue.SSR.NodeJS.server_path(), pool_size: 4]},
# ...
]
~H"""
<.vue
count={@count}
v-component="Counter"
v-socket={@socket}
v-on:inc={JS.push("inc")}
/>
"""
dev.exs
to loook like this - add notify
section and remove live|components
from patterns.# Watch static and templates for browser reloading.
config :my_app, MyAppWeb.Endpoint,
live_reload: [
notify: [
live_view: [
~r"lib/my_app_web/core_components.ex$",
~r"lib/my_app_web/(live|components)/.*(ex|heex)$"
]
],
patterns: [
~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
~r"lib/my_app_web/controllers/.*(ex|heex)$"
]
]
Voila! Easy, isn't it? 😉
By default, vue components should be placed either inside assets/vue
directory or colocated with your Elixir files. You can configure that behaviour by changing assets/vue/index.js
and use LiveVue.Components, vue_root: ["your/vue/dir"]
.
To render vue component from HEEX, you have to use <.vue>
function with these attributes:
Attribute | Example | Required | Description |
---|---|---|---|
v-component | v-component="Counter" v-component="helpers/modal" |
yes | Name of the component to render. Must match key defined in components passed to getHooks . By default you can use both filename or a full file path without extension, relative to assets/vue or lib/my_app_web |
v-socket | v-socket={@socket} |
Yes in LiveView | Used to determine if SSR is needed. Should be always included in LiveViews |
v-ssr | v-ssr={true} |
no | Defaults to Application.get_env(:live_vue, :ssr, true) |
v-on:event={@handler} | v-on:close={JS.toggle()} |
no | Handle component event by invoking JS hook. @handler has to come from JS module. See Usage section for more. |
prop={@value} | name="liveVue" count={@count} {%{count: 123}} |
no | All other attributes will be passed to vue component as props. Values have to be serializable to JSON, so structures have to implement Jason.Encoder protocol. |
Instead of writing <.vue v-component="Counter">
you can use shortcut <.Counter>
. Function names are generated based on filenames of found .vue
files, so assets/vue/helpers/nested/Modal.vue
will generate helper <.Modal>
. If there are multiple .vue
files with equal names, use <.vue v-component="path/to/file">
You pass props in the same way as with functional components in Elixir. All 3 examples does exactly the same.
<.vue
count={@count}
name={@name}
v-component="Counter"
v-socket={@socket}
/>
<.vue
v-component="Counter"
v-socket={@socket}
{%{count: @count, name: @name}}
/>
<.Counter
count={@count}
name={@name}
v-socket={@socket}
/>
All regular phoenix hooks like phx-click
, phx-submit
work as expected.
To keep components DRY you can define vue handlers using v-on:eventname={JS.handler()}
syntax.
All attributes starting with v-on:
are attached as emit handlers to Vue components and executed in the same way as Phoenix does it.
JS.push("someName")
is a special case - if JS.push defines no value, it will be replaced by the emit payload.
<.vue v-on:submit={JS.push("submit")} v-component="SomeForm" v-socket={@socket} />
<.vue
v-on:shoot={JS.push("shoot")}
v-on:close={JS.hide()}
v-component="SomeGame"
v-socket={@socket}
/>
You can even pass slots to the vue component! They're passed to vue as raw HTML, so hooks in the slots won't work. Each slot is wrapped in a div due to technical limitations.
:inner_block
and rendered inside Vue components as <slot />
.<:slot_name>Content</:slot_name>
syntax are rendered using <slot name="slot_name" />
syntax.An example:
<.Card title="The coldest sunset" v-socket={@socket}>
<p>This is card content passed from phoenix!</p>
<p>Even icons are working! <.icon name="hero-information-circle-mini" /></p>
<:footer>And this is a footer from phoenix</:footer>
</.Card>
<template>
<slot></slot>
Footer:
<slot name="footer"></slot>
</template>
You can use <.vue>
components in dead views. Of course, there will be no updates on assign changes, since there is no websocket connection established to support it.
v-socket={@socket} is not required in dead views.
Vue SSR is compiled down into string concatenation, so it's quite fast 😉
In development it's recommended to use config :live_vue, ssr_module: LiveVue.SSR.ViteJS
. It does HTTP call to vite /ssr_render
endpoint added by LiveVue plugin, which in turn uses vite ssrLoadModule for efficient compilation.
In production it's recommended to use config :live_vue, ssr_module: LiveVue.SSR.NodeJS
which uses NodeJS
package directly talking with a JS process with a in-memory server bundle. By default, SSR bundle is saved to priv/vue/server.js
.
You can use function useLiveVue
to access root phoenix element where Vue component was routed.
API of that object is described in Phoenix docs.
Example
<script>
import {useLiveVue} from "live_vue"
const hook = useLiveVue()
hook.pushEvent("hello", {value: "from Vue"})
</script>
We can go one step further and use LiveVue as an alternative to the standard LiveView DSL. This idea is taken from LiveSvelte
.
Take a look at the following example:
defmodule ExampleWeb.LiveSigil do
use ExampleWeb, :live_view
def render(assigns) do
~V"""
<script setup lang="ts">
import {ref} from "vue"
const props = defineProps<{count: number}>()
const diff = ref<number>(1)
</script>
<template>
Current count
<div class="text-2xl text-bold">{{ props.count }}</div>
<label class="block mt-8">Diff: </label>
<input v-model="diff" class="mt-4" type="range" min="1" max="10">
<button
phx-click="inc"
:phx-value-diff="diff"
class="mt-4 bg-black text-white rounded p-2 block">
Increase counter {{ diff }}
</button>
"""
end
def mount(_params, _session, socket) do
{:ok, assign(socket, count: 0)}
end
def handle_event("inc", %{"diff" => diff}, socket) do
{:noreply, update(socket, :count, &(&1 + String.to_integer(diff)))}
end
end
Use the ~V
sigil instead of ~H
and your LiveView will be Vue instead of an HEEx template.
Lazy loading Vue components is fully supported. You just need to return function returning promise in components
passed to getHooks(components)
.
It can be done by using eager: false
in import.meta.glob('./yourdir/*.vue', { eager: false, import: 'default' })
or by explicitly constructing components object. If SSR is enabled, all related JS and CSS files will be preloaded in HTML.
import component1 from "./Component1.vue"
import component2 from "./Component2.vue"
const entryComponents = {
Component1: component1,
Component2: component2,
Component3Lazy: () => import("./Component3.vue"),
}
// in app.js
const hooks = getHooks(entryComponents)
You can use /example_project
as a way to test live_vue
locally.
You can also use your own project.
Clone live_vue
to the parent directory of the project you want to test it in.
Inside mix.exs
{:live_vue, path: "../live_vue"},
Inside assets/package.json
"live_vue": "file:../../live_vue",
Make the changes in /assets/js
and run:
mix assets.build
Or run the watcher:
mix assets.build --watch
Release is done with expublish
package.
RELEASE.md
fileREADME.md
Run
git add README.md
git commit -m "README version bump"
# to ensure everything works fine
mix expublish.minor --dry-run --allow-untracked --branch=main
# to publish everything
mix expublish.minor --allow-untracked --branch=main
Deploying a LiveVue 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 LiveVue 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
Yes, I noticed it slightly too late to change. Some helpful reddit users pointed it out 😉
I'd suggest refering to it as LiveVuejs
in speech, to avoid confusion.
Both LiveVue
and LiveSvelte
serves the same purpose and are implemented in a very similar way. Here is a list of points to consider when choosing one over another:
ref
.Vue files in LiveVue have similar role as HEEX templates. In many cases it makes sense to colocate them next to your LiveViews for better DX.
You don't need to do anything to make it work, simply place your Vue files inside lib/my_app_web
directory and reference them by their names or relative paths.
The idea is fairly simple.
div
with props, slots and handlers as data
attributes. In live views these are kept in sync. When SSR is enabled, it also renders the component and inlines the result in the HTML.LiveVue
hook mount
callback initializes the element. It hooks up all the handlers, injects hook itself so useLiveVue
works correctly, and mounts the Vue component.One thing to keep in mind is that hooks are fired only after app.js
is fully loaded, so it might cause some delays of the initial render of the component.
As explained in the previous section, it takes a moment for Vue component to initialize, even if props are already inlined in the HTML.
It's done only during a "dead" render, without connected socket. It's not needed when doing live navigation - in my experience when using <.link navigate="...">
component is rendered before displaying a new page.
Not yet possible. Tracked in this issue.