Skip to main content
This guide explains the architecture and inner workings of LiveVue, helping you understand the design decisions and implementation details.
Looking for practical examples? Check out Basic Usage for common patterns and Quickstart for your first component.

Overview

LiveVue bridges two different paradigms: Phoenix LiveView with its server-side state management and HTML over WebSockets, and Vue.js with its client-side reactivity and virtual DOM. The challenge is making these two systems work together seamlessly while maintaining the benefits of both.

Component lifecycle

Server-side rendering (SSR)

When a LiveView renders a Vue component, LiveVue generates a special div element with component configuration stored in data attributes. Here’s what the server generates:
# In your LiveView template
<.vue v-component="MyComponent" message={@message} v-on:click="handle_click" />
This produces HTML like:
<div
  id="MyComponent-1"
  data-name="MyComponent"
  data-props="{&quot;message&quot;:&quot;Hello World&quot;}"
  data-handlers="{&quot;click&quot;:[&quot;push&quot;,{&quot;event&quot;:&quot;handle_click&quot;}]}"
  data-slots="{}"
  data-ssr="true"
  phx-hook="VueHook"
>
  <!-- Optional SSR content here -->
</div>
The component name, props serialized as JSON, event handlers, and slots are all embedded as data attributes, with an optional Phoenix LiveView hook attachment.

Client-side hydration

When the page loads and Phoenix LiveView connects, the VueHook activates. Here’s the simplified flow from hooks.ts:
export const getVueHook = ({ resolve, setup }: LiveVueApp): Hook => ({
  async mounted() {
    const componentName = this.el.getAttribute("data-name") as string
    const component = await resolve(componentName)

    const props = reactive(getProps(this.el, this.liveSocket))
    const slots = reactive(getSlots(this.el))

    const app = setup({
      createApp: makeApp,
      component,
      props,
      slots,
      // ... other options
    })

    this.vue = { props, slots, app }
  }
})
The hook:
  • Resolves the component name to the actual Vue component
  • Makes props and slots reactive using Vue’s reactivity system
  • Mounts the Vue component (optionally hydrating existing SSR content)
  • Configures event handlers for bidirectional communication

Reactive updates

When server state changes, LiveView sends new data via WebSocket:
  1. Phoenix updates only the changed data attributes
  2. Vue’s reactivity system automatically detects these changes
  3. Only affected parts of the Vue component re-render
This happens through the updated() hook:
updated() {
  if (this.el.getAttribute("data-use-diff") === "true") {
    applyPatch(this.vue.props, getDiff(this.el, "data-props-diff"))
  } else {
    Object.assign(this.vue.props, getProps(this.el, this.liveSocket))
  }
  Object.assign(this.vue.slots ?? {}, getSlots(this.el))
}

Props diffing system

LiveVue implements an efficient diffing system that minimizes data transmission by sending only changed properties as JSON patches.

Server-side diff construction

Change detection LiveView’s __changed__ tracking system identifies which assigns have been modified since the last render. For simple value changes, __changed__ contains true for the changed key. For complex data structures (maps, lists, structs), it stores the previous value to enable deep diffing. Struct encoding Before diffing can occur, any custom structs are converted to maps using the LiveVue.Encoder protocol:
# Example: User struct with encoder protocol
defmodule User do
  @derive {LiveVue.Encoder, except: [:password]}
  defstruct [:name, :email, :password, :created_at]
end

# When passed as props:
<.vue user={@current_user} v-component="Profile" v-socket={@socket} />

# The encoder converts the struct to:
%{
  name: "John Doe",
  email: "[email protected]",
  created_at: ~U[2023-01-01 12:00:00Z]
  # password field is excluded for security
}
Diff calculation The system processes each changed prop differently based on its complexity:
  • Simple values (strings, numbers, booleans): Generate a direct “replace” operation
  • Complex values (maps, lists, structs): Use the Jsonpatch library to calculate minimal differences
Path construction Each diff operation includes a JSON Pointer path that precisely identifies where the change should be applied:
# For nested changes, paths are constructed hierarchically
# Example: /user/email for changing a user's email field
old_value
|> Encoder.encode()
|> Jsonpatch.diff(new_value)
|> update_in([Access.all(), :path], fn path -> "/#{k}#{path}" end)

Client-side patch application

On the client side, the Vue hook processes these diffs efficiently:
const getDiff = (el: HTMLElement, attributeName: string): Operation[] => {
  const dataPropsDiff = getAttributeJson(el, attributeName) || []
  return dataPropsDiff.map(([op, path, value]: [string, string, any]) => ({
    op,
    path,
    value,
  }))
}
1

Diff extraction

When the updated() hook fires, it reads the data-props-diff attribute and parses the JSON array of patch operations.
2

Patch application

The system applies each patch operation to the reactive props object using the JSON Patch implementation in jsonPatch.ts.
3

Reactivity triggering

Since the props object is reactive (created with Vue’s reactive() function), any changes automatically trigger Vue’s reactivity system.
4

Efficient updates

Only the specific fields that changed are updated in the DOM.

Performance benefits

This diff-based approach provides several advantages:
  • Minimal network payload: Only changed data is transmitted
  • Efficient client-side updates: Only changed reactive properties trigger re-renders
  • Reduced memory pressure: Existing objects are patched rather than replaced
  • Faster UI updates: Smaller changes mean less work for Vue’s virtual DOM
Example: When only a user’s email changes:
# Instead of sending the entire user struct
%{user: %{name: "John", email: "[email protected]", created_at: ~U[...]}}

# LiveVue sends only the changed field
[%{op: "replace", path: "/user/email", value: "[email protected]"}]
For testing scenarios or debugging purposes, diffing can be disabled globally via the enable_props_diff: false configuration option, or per-component using the v-diff={false} attribute. When disabled, complete props are always sent instead of diffs.See Testing for details.

Data flow

Props flow (server → client)

LiveView manages authoritative state and passes it to Vue components as props:
  1. LiveView assigns are updated
  2. The HEEX template generates new prop data
  3. Only changed props are sent over WebSocket
  4. The Vue component automatically re-renders with new props
The server-side extraction logic ensures efficient updates:
defp extract(assigns, type) do
  Enum.reduce(assigns, %{}, fn {key, value}, acc ->
    case normalize_key(key, value) do
      ^type -> Map.put(acc, key, value)
      {^type, k} -> Map.put(acc, k, value)
      _ -> acc
    end
  end)
end

Event flow (client → server)

There are three main approaches for handling events: Standard Phoenix events (recommended for most cases):
<button phx-click="increment">Click me</button>
Programmatic events using useLiveVue().pushEvent():
const live = useLiveVue()
live.pushEvent("custom_event", { data: "value" })
Vue event handlers using the v-on: syntax:
<.vue v-component="Counter" v-on:increment="handle_increment" />
The event handlers are processed on the client side by invoking liveSocket.execJS with the payload defined by the JS module:
const getHandlers = (el: HTMLElement, liveSocket: any): Record<string, (event: any) => void> => {
  const handlers = getAttributeJson(el, "data-handlers") || {}
  const result: Record<string, (event: any) => void> = {}
  for (const handlerName in handlers) {
    const ops = handlers[handlerName]
    const snakeCaseName = `on${handlerName.charAt(0).toUpperCase() + handlerName.slice(1)}`
    result[snakeCaseName] = event => {
      const parsedOps = JSON.parse(ops)
      const replacedOps = parsedOps.map(([op, args, ...other]: [string, any, ...any[]]) => {
        if (op === "push" && !args.value) args.value = event
        return [op, args, ...other]
      })
      liveSocket.execJS(el, JSON.stringify(replacedOps))
    }
  }
  return result
}
Event handling best practices:
  • Use phx-click for simple, direct event handling
  • Use live.pushEvent() when you need programmatic control or complex logic
  • Use v-on: syntax when creating reusable Vue components that should be decoupled from specific LiveView implementations

SSR modes

LiveVue supports two different SSR implementations:

NodeJS mode (production)

The LiveVue.SSR.NodeJS module implements SSR by using a Node.js process pool:
def render(name, props, slots) do
  filename = Application.get_env(:live_vue, :ssr_filepath, "./static/server.mjs")

  NodeJS.call!({filename, "render"}, [name, props, slots],
    binary: true,
    esm: true
  )
end
This mode:
  • Uses the nodejs package to maintain a pool of Node.js processes
  • Calls the render function exposed by server.mjs
  • Provides fast SSR with minimal overhead
  • Requires Node.js 19+ in production

ViteJS mode (development)

The LiveVue.SSR.ViteJS module implements SSR by making HTTP requests to Vite dev server:
def render(name, props, slots) do
  data = Jason.encode!(%{name: name, props: props, slots: slots})
  url = vite_path("/ssr_render")
  params = {String.to_charlist(url), [], ~c"application/json", data}

  case :httpc.request(:post, params, [], []) do
    {:ok, {{_, 200, _}, _headers, body}} ->
      :erlang.list_to_binary(body)
    # ... error handling
  end
end
This mode:
  • Sends POST requests to http://{vite_host}/ssr_render
  • Uses the LiveVue Vite plugin to handle SSR
  • Provides hot module replacement during development
  • Simplifies the development workflow
SSR is intelligently applied only during initial page loads (dead renders), can be configured per component, and is skipped during live navigation for better performance.

Key design decisions

Hook-based integration

LiveVue uses Phoenix LiveView’s hook system rather than a separate JavaScript framework. This provides:
  • Seamless integration within LiveView’s lifecycle
  • Automatic cleanup when elements are removed
  • Natural compatibility with all Phoenix events

Reactive props and slots

Props and slots are made reactive using Vue’s reactivity system, enabling:
  • Efficient updates where only changed data triggers re-renders
  • Full compatibility with Vue features like computed properties and watchers
  • Minimal overhead for prop updates

Selective updates

LiveVue minimizes data transmission by:
  • Tracking only modified props, slots, and handlers
  • Optimizing JSON encoding to prevent redundant work
  • Phoenix updating only specific data attributes rather than re-rendering entire elements

Memory management

Automatic cleanup prevents memory leaks through proper hook lifecycle management:
destroyed() {
  const instance = this.vue.app
  if (instance) {
    window.addEventListener("phx:page-loading-stop", () => instance.unmount(), { once: true })
  }
}
Vue apps are unmounted when hooks are destroyed, with special handling for Phoenix navigation events and automatic removal of event listeners.

Slots implementation

Slots bridge HEEX templates and Vue components:
const getSlots = (el: HTMLElement): Record<string, () => any> => {
  const dataSlots = getAttributeJson(el, "data-slots") || {}
  return mapValues(dataSlots, base64 => () => h("div", { innerHTML: fromUtf8Base64(base64).trim() }))
}
Slots are:
  1. Rendered server-side as HTML
  2. Encoded as Base64 for safe transport
  3. Decoded on the client for integration into Vue’s slot system
Limitation: Since slots are rendered server-side, they can’t contain other Vue components or Phoenix hooks.

Security considerations

Data sanitization

All data passed between server and client is properly sanitized:
  • Props are safely encoded with HTML escaping using Jason.encode!(data, escape: :html_safe)
  • All user data is escaped before transmission
  • Events go through Phoenix’s standard validation

Event security

Event handling maintains Phoenix’s security model:
  • All events are validated on the server
  • Standard Phoenix CSRF protection applies
  • LiveView’s authorization patterns work normally

Debugging and development

Development tools

LiveVue works with standard development tools:
  • Vue DevTools for full component inspection and debugging
  • Phoenix LiveView Dashboard for server-side state monitoring
  • Browser DevTools for network and WebSocket inspection

Debug features

Built-in debugging capabilities include:
  • Debug mode for detailed logging of component lifecycle
  • Component resolution logs to help identify loading issues
  • Event tracing to track events flowing between Vue and LiveView

Limitations and trade-offs

Current limitations

  • Vue components can’t contain other Vue components
  • Phoenix hooks don’t work inside slots
  • Limited browser API access during server rendering

Design trade-offs

  • The Vue runtime adds approximately 34KB gzipped to your application
  • Additional abstraction layer between Phoenix and the client
  • Requires understanding both Phoenix LiveView and Vue.js

When to use LiveVue

LiveVue is a good fit for:
  • Complex client-side interactions
  • Rich UI components with local state
  • Leveraging the Vue ecosystem (animations, charts, etc.)
  • Teams with Vue.js expertise
Consider alternatives for:
  • Simple forms and basic interactions
  • Applications prioritizing minimal JavaScript
  • Teams without Vue.js experience

Next steps

Configuration

Customize behavior and SSR settings

Basic usage

Practical patterns and examples

Client-side API

Vue composables and utilities

Testing

Test Vue components in LiveView

Build docs developers (and LLMs) love