This document outlines how to build idiomatic Phoenix LiveView applications using the Haxe→Elixir compiler, based on analysis of official Phoenix LiveView examples and best practices.
Phoenix LiveView fundamentally changes the traditional web application architecture:
-
Server-Side Rendering with Real-Time Updates
- Server renders HTML and sends it to the client
- WebSocket connection maintains real-time bidirectional communication
- Client receives DOM patches, not JSON data
-
Server as Single Source of Truth
- All business logic lives on the server
- All data operations happen server-side
- Client state is minimal and ephemeral
-
Minimal Client JavaScript
- Client code should be < 200 lines for most applications
- Focus on DOM enhancement, not data management
- No client-side routing, API calls, or state management
| Traditional SPA | Phoenix LiveView |
|---|---|
| Complex client routing | Server-side routing |
| Client-side state management | Server-side state |
| JSON API endpoints | WebSocket events |
| Client data fetching | Server data streaming |
| 1000+ lines of JS | < 200 lines of JS |
File: assets/js/app.js (60 lines total)
// 1. Basic imports
import "phoenix_html"
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar"
// 2. Single hook for form clearing
let Hooks = {}
Hooks.Form = {
updated() {
// Clear input if no validation errors
if(document.getElementsByClassName('invalid-feedback').length == 0) {
msg.value = '';
}
}
}
// 3. LiveSocket setup
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken},
hooks: Hooks
})
// 4. Progress bar integration
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
window.addEventListener("phx:page-loading-start", info => topbar.show())
window.addEventListener("phx:page-loading-stop", info => topbar.hide())
// 5. Connect and expose for debugging
liveSocket.connect()
window.liveSocket = liveSocketKey Observations:
- No async/await patterns - Not needed for LiveView
- No data fetching - Server pushes data via WebSocket
- Single hook - Only for DOM manipulation (form clearing)
- No client-side routing - Server handles all navigation
- No error handling/retry logic - LiveView handles reconnection
File: assets/js/app.js (45 lines total)
import "phoenix_html"
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken}
})
// Progress bar integration
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
liveSocket.connect()
window.liveSocket = liveSocketEven simpler:
- No hooks at all - Pure LiveView with zero client customization
- Just LiveSocket + progress bar - Absolute minimum needed
Traditional Phoenix LiveView JS → Haxe (Genes) + minimal JS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
phoenix_app.js bootstrap → keep as minimal JS (Phoenix conventions)
Hooks = {} (object) → HookName + HookRegistry (compile-time checked)
Hook implementations → Haxe client modules (Genes) → window.Hooks
Manual DOM queries → Type-safe Element references
Dynamic event handling → Strongly-typed event signatures
No compilation → Haxe compile-time validation
-
Shared, type-safe hook names
@:phxHookNames enum abstract HookName(String) from String to String { var AutoFocus = "AutoFocus"; var ThemeToggle = "ThemeToggle"; } // Server templates: // phx-hook=${HookName.AutoFocus}
Compiles to HEEx usage shape:
<div phx-hook={"AutoFocus"}></div>
-
Compile-time hook registry (Genes)
// Client entrypoint (compiled via -lib genes) var hooks = HookRegistry.build({ AutoFocus: { mounted: function(): Void { AutoFocusHook.mounted(hookContext()); } }, ThemeToggle: { mounted: function(): Void { ThemeToggleHook.mounted(hookContext()); } } });
Compiles to JS shape:
window.Hooks = Object.assign(window.Hooks || {}, { AutoFocus: { mounted() { /* ... */ } }, ThemeToggle: { mounted() { /* ... */ } } });
-
Minimal JS bootstrap boundary
- Keep
LiveSocketsetup in a smallassets/js/phoenix_app.jsthat:- imports Phoenix JS dependencies
- imports the Haxe-generated client bundle
- reads CSRF meta token and connects
LiveSocketwithhooks: window.Hooks
- Keep
phoenix_app.js (JS, kept small and idiomatic)
import "phoenix_html";
import {Socket} from "phoenix";
import {LiveSocket} from "phoenix_live_view";
import "./app.js"; // imports Haxe-generated client bundle (hx_app.js)
const csrfToken = document.querySelector("meta[name='csrf-token']")?.getAttribute("content");
const hooks = window.Hooks || {};
const liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}, hooks});
liveSocket.connect();
window.liveSocket = liveSocket;client.Boot (Haxe, compiled to JS via Genes)
class Boot {
public static function main() {
var hooks = HookRegistry.build({
AutoFocus: { mounted: function(): Void { AutoFocusHook.mounted(hookContext()); } }
});
js.Syntax.code("window.Hooks = Object.assign(window.Hooks || {}, {0})", hooks);
}
}class AutoFocusHook {
public static function mounted(ctx: PhoenixHookContext): Void {
// Simple DOM enhancement
ctx.el.focus();
}
}class TodoFormHook {
public static function updated(ctx: PhoenixHookContext): Void {
// Server handles validation; client can do small DOM-only UX updates.
var input = ctx.el.querySelector('input[type="text"]');
if (input != null) input.value = "";
}
}// ❌ WRONG: Client fetching data
@:async
public static function fetchTodosAsync(): js.lib.Promise<Array<Todo>> {
// This violates LiveView philosophy!
return fetch("/api/todos").then(response => response.json());
}
// ✅ CORRECT: Server pushes data
// No client code needed - LiveView handles it automatically// ❌ WRONG: Client managing state
class TodoStore {
private static var todos: Array<Todo> = [];
public static function addTodo(todo: Todo): Void {
todos.push(todo);
// Complex state synchronization...
}
}
// ✅ CORRECT: Server manages state
// Just push events to server:
pushEvent("add_todo", {title: title});// ❌ WRONG: Client error handling/retry
@:async
private static function logErrorToServerAsync(error: String): js.lib.Promise<Void> {
// Complex retry logic, queuing, batching...
return retryWithBackoff(() => sendError(error));
}
// ✅ CORRECT: Server logs errors via Phoenix.Logger
// Client just reports to console (if anything):
trace('Client error: ${error}');// ❌ WRONG: Client-side routing
class Router {
public static function navigateTo(path: String): Void {
// Client-side route handling...
}
}
// ✅ CORRECT: Server-side routing via Phoenix Router
// Use pushEvent or live_redirect:
pushEvent("navigate", {path: path});Strict Guidelines:
- Main application file: < 200 lines
- Individual hooks: < 50 lines each
- Total client code: < 500 lines for most apps
- Zero async data operations (except legitimate UI needs)
| Lifecycle Method | Purpose | Example Use Case |
|---|---|---|
mounted() |
Initial DOM setup | Focus input, setup event listeners |
beforeUpdate() |
Pre-update preparation | Save scroll position |
updated() |
React to server changes | Clear form on success, restore scroll |
destroyed() |
Cleanup | Remove event listeners |
disconnected() |
Connection lost | Show offline indicator |
reconnected() |
Connection restored | Hide offline indicator |
Everything important happens on the server:
-
Data Operations
# In LiveView module def handle_event("create_todo", %{"title" => title}, socket) do case Todos.create_todo(%{title: title}) do {:ok, todo} -> # Server handles success: broadcast to all clients TodoApp.PubSub.broadcast("todos", {:todo_created, todo}) {:noreply, socket} {:error, changeset} -> # Server handles errors: show validation {:noreply, assign(socket, changeset: changeset)} end end
-
Real-Time Updates
# Server pushes updates to all connected clients def handle_info({:todo_created, todo}, socket) do {:noreply, assign(socket, todos: [todo | socket.assigns.todos])} end
-
State Management
# All state lives in socket.assigns def mount(_params, _session, socket) do if connected?(socket) do TodoApp.PubSub.subscribe("todos") end {:ok, assign(socket, todos: Todos.list_todos())} end
Primary testing effort should be on LiveView modules:
test "creates todo and broadcasts to connected clients", %{conn: conn} do
{:ok, view, html} = live(conn, "/")
# Test server event handling
view
|> form("#todo-form", todo: %{title: "New todo"})
|> render_submit()
# Verify server state changed
assert has_element?(view, "#todo-list li", "New todo")
endOnly test DOM manipulation in hooks:
// Test hook behavior, not data logic
class AutoFocusHookTest {
@:test
public function testFocusesOnMount(): Void {
var el = createMockElement();
var hook = new AutoFocusHook();
hook.el = el;
hook.mounted();
Assert.isTrue(el.focused);
}
}-
Automatic Optimization
- DOM diffing and minimal patches
- Connection management and reconnection
- Compression and batching
-
Server-Side Benefits
- Database connection pooling
- Shared memory across users
- Efficient query optimization
-
Client-Side Benefits
- No bundle size concerns (minimal JS)
- No client-side memory leaks
- Automatic cleanup on disconnection
Legitimate use cases for async patterns:
-
File Operations
@:async public static function uploadFileAsync(file: File): js.lib.Promise<Void> { // File upload with progress tracking return fileUploader.upload(file); }
-
Client-Side Image Processing
@:async public static function resizeImageAsync(image: ImageElement): js.lib.Promise<Blob> { // Canvas manipulation, image filtering return imageProcessor.resize(image); }
-
Animation Sequences
@:async public static function animateTransitionAsync(element: Element): js.lib.Promise<Void> { // Complex CSS animation chains return animator.fadeOut(element).then(() => animator.slideIn(element)); }
Key Point: Async is for client-side operations only, never for server communication in LiveView apps.
- Phoenix LiveView Docs: https://hexdocs.pm/phoenix_live_view/
- Example App:
examples/todo-app - Haxe→Elixir Patterns: Phoenix LiveView Patterns
- Implementation Guide: Phoenix Integration Guide
- Testing Approach: ExUnit Testing
Remember: Phoenix LiveView's power comes from doing LESS on the client, not more. Embrace the simplicity!