This document outlines best practices, conventions, and usage patterns for the LiveVue library. Following these guidelines will help you build maintainable, performant applications that leverage Vue.js components within Phoenix LiveView.
Key Principle: The LiveView holds the source of truth. Vue components are reactive views of server state with their own client-side state.
DO keep Vue components in the assets/vue directory. Organize them in a sensible way:
assets/
vue/
index.ts # this is the entry point for the Vue app
components/
ui/
Button.vue
Modal.vue
forms/
ContactForm.vue
pages/
Dashboard.vue
shared/
Layout.vue
DO use PascalCase for component file names, longer than a single word:
✅ UserProfile.vue
✅ ShoppingCart.vue
✅ ContactForm.vue
DO NOT use kebab-case or snake_case for file names:
❌ user-profile.vue
❌ shopping_cart.vue
Use the same name in the v-component attribute (match case exactly, without the extension). Always pass the socket to the component:
<.vue v-component="UserProfile" user={@user} v-socket={@socket} />DO pass all necessary data as props from LiveView. Always pass the socket to the component:
<.vue
v-component="ShoppingCart"
v-socket={@socket}
cartItems={@cart_items}
cartTotal={@cart_total}
currency={@currency}
/>DO NOT rely on Vue components to fetch their own data:
❌ <!-- WRONG: Fetching data in Vue component -->
<script setup>
import { onMounted, ref } from 'vue'
const items = ref([])
onMounted(async () => {
const response = await fetch('/api/cart')
items.value = await response.json()
})
</script>DO implement the LiveVue.Encoder protocol for custom structs:
defmodule MyApp.User do
# You can derive the protocol if it doesn't need any customization
@derive {LiveVue.Encoder, only: [:id, :name, :email]}
defstruct [:id, :name, :email, :private_field]
end
defimpl LiveVue.Encoder, for: MyApp.User do
def encode(user) do
%{
id: user.id,
name: user.name,
email: user.email
# private_field intentionally omitted
}
end
endDO NOT pass structs without implementing the encoder protocol:
❌ # This will raise Protocol.UndefinedError
<.vue v-component="UserCard" user={%MyApp.User{}} />Use Phoenix event handlers for most interactions:
defmodule MyApp.Live.ContactForm do
use LiveVue, :live_view
def handle_event("like_post", %{"post_id" => post_id}, socket) do
# handle the event here
{:noreply, socket}
end
endUse useLiveVue().pushEvent() or in the template $live.pushEvent() API for dynamic events. useLiveVue() and $live are the same thing - Vue phoenix hook instance.
<script setup>
import { useLiveVue } from 'live_vue'
const live = useLiveVue()
const handleCustomAction = (data) => {
live.pushEvent('custom_action', data)
}
</script>
<template>
<!-- You can also use $live directly in templates -->
<button @click="$live.pushEvent('simple_action', { value: 'hello' })">
Click me
</button>
</template>DO use useLiveEvent() for server-to-client communication. It handles component lifecycle correctly.
<script setup>
import { useLiveEvent } from 'live_vue'
useLiveEvent('notification', (data) => {
// Handle server-sent notification
console.log('Received:', data)
})
</script>By default, live_vue uses SSR.DO disable SSR for components with client-only dependencies:
<.vue
v-component="ClientOnlyMap"
v-socket={@socket}
v-ssr={false}
/><script setup>
import { Link } from 'live_vue'
</script>
<template>
<!-- Normal link -->
<Link href="/">Home</Link>
<!-- Navigate to a different route -->
<Link navigate="/users">Users</Link>
<!-- Patch the current route with different params -->
<Link patch="/users/3">User 3</Link>
<!-- Patch the current route with query params and replace the history -->
<Link patch="/users/3?details=true" replace>User 3 with details</Link>
</template>Use useLiveNavigation() for programmatic navigation:
<script setup>
import { useLiveNavigation } from 'live_vue'
const { patch, navigate } = useLiveNavigation()
// Same route, different params
const updateUser = (user) => patch(`/users/${user.id}`)
// Same route, different query params with replace history
const goToTab = (tab) => patch({ tab: tab }, { replace: true })
// Different route
const goToPage = (path) => navigate(path)
</script>Prefer <Link> components in templates, unless not possible.
Use useLiveUpload() for file upload functionality. Server-side upload is supported by LiveView in the exact same way as when using HEEX templates.
<script setup>
import { useLiveUpload } from 'live_vue'
const {
entries,
progress,
showFilePicker,
addFiles,
submit,
cancel,
clear,
valid
} = useLiveUpload(
() => props.uploadConfig,
{
changeEvent: 'validate_upload',
submitEvent: 'save_upload'
}
)
</script>Use addFiles() for drag-and-drop:
<template>
<div
@drop.prevent="addFiles($event.dataTransfer)"
@dragover.prevent
class="upload-zone"
>
<p v-if="entries.length === 0">Drop files here or</p>
<button @click="showFilePicker">Choose Files</button>
<!-- Show upload progress -->
<div v-if="entries.length > 0" class="upload-progress">
<div v-for="entry in entries" :key="entry.ref">
{{ entry.client_name }} - {{ entry.progress }}%
<button @click="cancel(entry.ref)">Cancel</button>
</div>
<p>Overall progress: {{ progress }}%</p>
<p v-if="!valid" class="error">Upload has errors</p>
</div>
</div>
</template>Test Vue components through LiveView integration:
test "renders user profile component", %{conn: conn} do
{:ok, view, _html} = live(conn, "/users/1")
# Get Vue component by name or id. Optional if there is only one component on the page.
vue_config = LiveVue.Test.get_vue(view, name: "UserProfile")
# or by ID: vue_config = LiveVue.Test.get_vue(view, id: "user-profile-1")
# or without any arguments: vue_config = LiveVue.Test.get_vue(view)
assert vue_config.props["name"] == "John Doe"
assert vue_config.props["email"] == "john@example.com"
assert vue_config.component == "UserProfile"
render_hook(view, "toggle_details", %{"details" => true})
# Details should now be true.
%{props: props} = LiveVue.Test.get_vue(view)
assert props["details"] == true
endProblem: Component is not found on the client side Solution:
- Make sure you use the correct name in the
v-componentattribute (should match file name exactly, without the extension). - Restart the server to pick up newly created components.
- Ensure resolve function can find that component in
assets/vue/index.ts.
Use useLiveForm() for complex forms with validation, arrays, and nested objects:
<script setup>
import { Form, useLiveForm } from 'live_vue'
type UserForm = {
name: string
email: string
tags: string[]
profile: {
bio: string
skills: Array<{ name: string; level: string }>
}
}
const props = defineProps<{ form: Form<UserForm> }>()
const form = useLiveForm(() => props.form, {
changeEvent: 'validate', // Event sent on field changes (null to disable)
submitEvent: 'submit', // Event sent on form submission
debounceInMiliseconds: 300, // Debounce validation requests
prepareData: (data) => data // Transform data before sending
})
// Basic field access
const nameField = form.field('name')
const emailField = form.field('email')
// Nested object fields
const bioField = form.field('profile.bio')
// Array fields
const tagsArray = form.fieldArray('tags')
const skillsArray = form.fieldArray('profile.skills')
// Nested array fields are also supported
const firstSkillNameField = form.field('profile.skills[0].name')
// Field operations
const addTag = () => tagsArray.add('')
const removeTag = (index) => tagsArray.remove(index)
</script>
<template>
<!-- Basic field with validation -->
<input
v-bind="nameField.inputAttrs.value"
:class="{ 'error': nameField.isTouched.value && nameField.errorMessage.value }"
/>
<div v-if="nameField.errorMessage.value">
{{ nameField.errorMessage.value }}
</div>
<!-- Array iteration -->
<div v-for="(tagField, index) in tagsArray.fields.value" :key="index">
<input v-bind="tagField.inputAttrs.value" />
<button @click="removeTag(index)">Remove</button>
</div>
<!-- Form actions -->
<button @click="form.submit()" :disabled="!form.isValid.value">
Submit
</button>
<button @click="form.reset()">Reset</button>
</template>Each field provides reactive state and helpers:
interface FormField<T> {
// Reactive state
value: Ref<T> // Current field value
errors: Ref<string[]> // Validation errors from server
errorMessage: Ref<string> // First error message
isValid: Ref<boolean> // No validation errors
isDirty: Ref<boolean> // Value changed from initial
isTouched: Ref<boolean> // Field has been interacted with
// Input binding helper (includes value, events, accessibility)
inputAttrs: Ref<{
value: T
onInput: (event: Event) => void
onFocus: () => void
onBlur: () => void
name: string
id: string
'aria-invalid': boolean
'aria-describedby'?: string
}>
// Navigation methods for nested structures
field(key): FormField // Access nested object field
fieldArray(key): FormFieldArray // Access nested array field
}
interface FormFieldArray<T> extends FormField<T[]> {
// Array-specific methods
add: (item?: Partial<T>) => void
remove: (index: number) => void
move: (from: number, to: number) => void
// Reactive array of field instances for iteration
fields: Readonly<Ref<FormField<T>[]>>
}
interface UseLiveFormReturn<T extends object> {
// Form-level state
isValid: Ref<boolean>
isDirty: Ref<boolean>
isTouched: Ref<boolean>
submitCount: Readonly<Ref<number>>
initialValues: Readonly<Ref<T>>
// Type-safe field factory functions
field(key): FormField
fieldArray(key): FormFieldArray
// Form actions
submit: () => Promise<void>
reset: () => void
}Set up server-side forms in the standard way:
defmodule MyApp.Live.FormTest do
use LiveVue, :live_view
def render(assigns) do
~H"""
<.vue form={@form} v-component="UserForm" v-socket={@socket} />
"""
end
def mount(params, socket) do
changeset = MyApp.User.changeset(%MyApp.User{}, %{})
socket = assign(socket, form: to_form(changeset, as: :user))
{:ok, socket}
end
def handle_event("validate", params, socket) do
changeset = MyApp.User.changeset(%MyApp.User{}, params)
{:noreply, assign(socket, form: to_form(changeset, as: :user))}
end
def handle_event("submit", params, socket) do
changeset = MyApp.User.changeset(%MyApp.User{}, params)
case Repo.insert(changeset) do
{:ok, _user} ->
{:noreply, socket}
{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset, as: :user))}
end
end
endDO NOT use Vue state stores (Pinia, Vuex) for application state:
❌ <!-- WRONG: Using Pinia for app state -->
<script setup>
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
</script>DO use LiveView state with reactive props:
✅ <!-- CORRECT: Server-side state -->
def handle_event("update_user", params, socket) do
# Update state on server
{:noreply, assign(socket, user: updated_user)}
end