Event Handling
Event handling is at the core of PyView’s interactivity. This guide covers how to handle user interactions, process form data, and manage different types of events in your LiveView applications.
Event Types
PyView supports several types of events for different user interactions:
User Interface Events
Events triggered by user interactions with your HTML elements:
phx-click- Button clicks and element interactionsphx-change- Input field changes (text, select, checkbox, etc.)phx-submit- Form submissionsphx-blur- Element loses focusphx-focus- Element gains focusphx-keydown- Key press eventsphx-keyup- Key release events
Server Events
Events generated on the server side:
- InfoEvent - Scheduled events, pub/sub messages, and server-side triggers
JavaScript Hook Events
Events sent from JavaScript hooks using this.pushEvent():
- Custom events with arbitrary payloads from client-side code
- Useful for integrating third-party libraries, drag-and-drop, maps, etc.
Basic Event Handling
Standard Method
The basic way to handle events is through the handle_event() method. PyView automatically extracts and converts parameters from the event payload based on your method signature:
from pyview import LiveView, LiveViewSocketfrom typing import TypedDict
class CounterContext(TypedDict): count: int
class CounterLiveView(LiveView[CounterContext]): async def mount(self, socket: LiveViewSocket[CounterContext], session): socket.context = {"count": 0}
async def handle_event(self, event: str, socket: LiveViewSocket[CounterContext]): if event == "increment": socket.context["count"] += 1 elif event == "decrement": socket.context["count"] -= 1 elif event == "reset": socket.context["count"] = 0Template:
<div> <h1>Count: {{count}}</h1> <button phx-click="increment">+</button> <button phx-click="decrement">-</button> <button phx-click="reset">Reset</button></div>Decorator-Based Event Handling
For better organization, use the @event decorator with BaseEventHandler:
from pyview import LiveView, LiveViewSocketfrom pyview.events import BaseEventHandler, event
class CounterLiveView(BaseEventHandler, LiveView[CounterContext]): async def mount(self, socket: LiveViewSocket[CounterContext], session): socket.context = {"count": 0}
@event("increment") async def handle_increment(self, socket: LiveViewSocket[CounterContext], amount: int = 1): socket.context["count"] += amount
@event("decrement") async def handle_decrement(self, socket: LiveViewSocket[CounterContext], amount: int = 1): socket.context["count"] -= amount
@event("reset") async def handle_reset(self, socket: LiveViewSocket[CounterContext]): socket.context["count"] = 0Note: Method names are arbitrary when using
@event("name"). Thehandle_prefix shown here is a convention for readability, not a requirement.
The BaseEventHandler will automatically route events to the correct method.
Legacy style (still supported)
@event("increment")async def handle_increment(self, event: str, payload: dict, socket: LiveViewSocket[CounterContext]): amount = int(payload.get("amount", [1])[0]) socket.context["count"] += amountAutoEventDispatch
AutoEventDispatch extends BaseEventHandler with an additional feature: methods decorated with @event can be referenced directly in templates, automatically converting to their event name string.
This is especially useful with T-String Templates, where you can reference methods directly:
from pyview import LiveView, LiveViewSocketfrom pyview.events import AutoEventDispatch, eventfrom pyview.template import TemplateView
class CounterLiveView(AutoEventDispatch, TemplateView, LiveView[CounterContext]): async def mount(self, socket: LiveViewSocket[CounterContext], session): socket.context = {"count": 0}
@event async def increment(self, socket): socket.context["count"] += 1
@event async def decrement(self, socket): socket.context["count"] -= 1
def template(self, assigns, meta): count = assigns["count"] return t"""<div> <h1>{count}</h1> <button phx-click="{self.decrement}">-</button> <button phx-click="{self.increment}">+</button> </div>"""Notice phx-click="{self.increment}" - the method reference automatically converts to the event name string "increment". This eliminates string duplication and enables IDE navigation from template to handler.
Key features:
- Methods stringify to their event name when used in templates
- Works with both
@event(uses method name) and@event("custom-name") - Methods remain callable for direct invocation in tests
Using custom event names:
@event("user-clicked-save")async def handle_save(self, socket): # self.handle_save stringifies to "user-clicked-save" passEvent Payloads
Event payloads contain different data depending on the event type and source element.
Button Click Events
Simple click events typically have minimal payload:
# Template: <button phx-click="save">Save</button>async def handle_event(self, event: str, socket): if event == "save": await save_data(socket.context["data"])Button Click with Values
Use phx-value-* attributes to pass data. PyView automatically extracts and converts these values based on your method signature:
<!-- Template --><button phx-click="delete_user" phx-value-user-id="{{user.id}}">Delete</button><button phx-click="set_status" phx-value-status="active" phx-value-user-id="{{user.id}}">Activate</button># New style - typed parameters are automatically extracted from phx-value-* attributesasync def handle_event(self, event: str, socket, user_id: str, status: str = "active"): if event == "delete_user": await delete_user(user_id) elif event == "set_status": await update_user_status(user_id, status)Legacy style (still supported)
async def handle_event(self, event, payload, socket): if event == "delete_user": user_id = payload["user_id"] # From phx-value-user-id await delete_user(user_id) elif event == "set_status": user_id = payload["user_id"] # From phx-value-user-id status = payload["status"] # From phx-value-status await update_user_status(user_id, status)Form Change Events
Form inputs send their current value. With typed parameters, values are automatically extracted and converted:
<!-- Template --><input type="text" phx-change="search" name="query" value="{{search_query}}"><select phx-change="filter_category" name="category"> <option value="all">All Categories</option> <option value="books">Books</option> <option value="movies">Movies</option></select># New style - typed parameters are automatically extracted from form fieldsasync def handle_event(self, event: str, socket, query: str = "", category: str = "all"): if event == "search": socket.context["search_query"] = query socket.context["results"] = await search_items(query) elif event == "filter_category": socket.context["selected_category"] = category socket.context["items"] = await filter_by_category(category)Legacy style (still supported)
async def handle_event(self, event, payload, socket): if event == "search": # payload: {"query": ["user typed text"]} query = payload.get("query", [""])[0] socket.context["search_query"] = query socket.context["results"] = await search_items(query) elif event == "filter_category": # payload: {"category": ["books"]} category = payload.get("category", ["all"])[0] socket.context["selected_category"] = category socket.context["items"] = await filter_by_category(category)Note: Form values in the raw payload are always lists (e.g., ["value"]) to support multi-select elements. Typed parameter binding handles this automatically.
Form Submission Events
Form submissions include all form fields. Use typed parameters or dataclasses to cleanly extract form data:
<!-- Template --><form phx-submit="create_user" phx-change="validate"> <input type="text" name="name" value="{{changeset.attrs.name}}"> <input type="email" name="email" value="{{changeset.attrs.email}}"> <select name="role"> <option value="user">User</option> <option value="admin">Admin</option> </select> <button type="submit">Create User</button></form>from dataclasses import dataclass
@dataclassclass UserForm: name: str email: str role: str = "user"
# New style - use a dataclass to group form fieldsasync def handle_event(self, event: str, socket, user: UserForm): if event == "create_user": try: created = await create_user({"name": user.name, "email": user.email, "role": user.role}) socket.context["users"].append(created) socket.context["success"] = "User created successfully" except ValidationError as e: socket.context["error"] = str(e) elif event == "validate": errors = validate_user_data(user) socket.context["errors"] = errorsOr use individual typed parameters:
async def handle_event(self, event: str, socket, name: str, email: str, role: str = "user"): if event == "create_user": await create_user({"name": name, "email": email, "role": role})Legacy style (still supported)
async def handle_event(self, event, payload, socket): if event == "create_user": # payload: { # "name": ["John Doe"], # "email": ["john@example.com"], # "role": ["user"] # } user_data = { "name": payload.get("name", [""])[0], "email": payload.get("email", [""])[0], "role": payload.get("role", ["user"])[0] } try: user = await create_user(user_data) socket.context["users"].append(user) except ValidationError as e: socket.context["error"] = str(e)Advanced Event Handling
Event Parameters and Values
Extract specific data from events using phx-value-* attributes. Typed parameters handle the extraction and conversion automatically:
<!-- Multi-parameter events --><button phx-click="move_item" phx-value-item-id="{{item.id}}" phx-value-from-list="todo" phx-value-to-list="done" phx-value-position="0"> Mark Done</button>@event("move_item")async def handle_move_item(self, socket, item_id: str, from_list: str, to_list: str, position: int): await move_item(item_id, from_list, to_list, position) socket.context["items"] = await reload_items()Legacy style (still supported)
@event("move_item")async def handle_move_item(self, event, payload, socket): item_id = payload["item_id"] from_list = payload["from_list"] to_list = payload["to_list"] position = int(payload["position"]) await move_item(item_id, from_list, to_list, position)Custom Event Data
Send complex data using JavaScript hooks (advanced):
<!-- Template with hook --><div id="kanban-board" phx-hook="KanbanBoard"> <!-- Kanban board content --></div>// JavaScript hookHooks.KanbanBoard = { mounted() { // Setup drag & drop that sends custom events this.el.addEventListener('item-moved', (e) => { this.pushEvent("task-moved", { taskId: e.detail.taskId, from: e.detail.from, to: e.detail.to, order: e.detail.order }); }); }}@event("task-moved")async def handle_task_moved(self, socket, taskId: str, to: str, order: int, payload: dict): # Mix typed params with full payload access # 'from' is a Python keyword, so access it via payload from_list = payload["from"] socket.context.task_repository.move_task(taskId, from_list, to, order)Note: The
payloadparameter is injectable—include it in your signature to get the full payload alongside typed parameters. This is useful when payload keys conflict with Python keywords.
Legacy style (still supported)
@event("task-moved")async def handle_task_moved(self, event, payload, socket): task_id = payload["taskId"] from_list = payload["from"] to_list = payload["to"] order = payload["order"] socket.context.task_repository.move_task(task_id, from_list, to_list, order)Form Handling Patterns
Input Validation
Validate on change and submission:
from pyview.changesets import change_set, ChangeSet
class UserContext(TypedDict): changeset: ChangeSet users: list[dict]
class UserLiveView(LiveView[UserContext]): async def mount(self, socket: LiveViewSocket[UserContext], session): socket.context = { "changeset": change_set(User), "users": [] }
@event("validate") async def handle_validate(self, socket, payload: dict): # Changesets use the raw payload dict socket.context["changeset"].apply(payload) # Validation happens automatically
@event("save_user") async def handle_save(self, socket, payload: dict): socket.context["changeset"].apply(payload)
if socket.context["changeset"].valid: user = socket.context["changeset"].model await save_user(user) socket.context["users"].append(user) # Reset form socket.context["changeset"] = change_set(User) # Errors are automatically displayed in templateServer-Side Events (InfoEvent)
Handle scheduled events and pub/sub messages:
from pyview.events import InfoEvent, info
class ChatLiveView(BaseEventHandler, LiveView[ChatContext]): async def mount(self, socket: LiveViewSocket[ChatContext], session): socket.context = {"messages": [], "user_id": session["user_id"]}
if is_connected(socket): # Subscribe to chat updates await socket.subscribe("chat_room") # Schedule periodic cleanup socket.schedule_info(InfoEvent("cleanup"), 300) # Every 5 minutes
@info("chat_room") async def handle_chat_message(self, event: InfoEvent, socket): # Received message from another user message = event.payload socket.context["messages"].append(message)
@info("cleanup") async def handle_cleanup(self, event: InfoEvent, socket): # Remove old messages cutoff_time = datetime.now() - timedelta(hours=24) socket.context["messages"] = [ msg for msg in socket.context["messages"] if msg["timestamp"] > cutoff_time ]
@event("send_message") async def handle_send_message(self, socket, text: str): message = { "user_id": socket.context["user_id"], "text": text, "timestamp": datetime.now() } # Broadcast to all chat subscribers await socket.broadcast("chat_room", message)Async Streams
For long-running async operations that yield multiple values (like streaming LLM responses), use socket.stream_runner:
class ChatLiveView(BaseEventHandler, LiveView[ChatContext]): async def mount(self, socket, session): socket.context = {"response": "", "streaming": False}
@event("ask") async def handle_ask(self, socket, question: str): socket.context["streaming"] = True socket.context["response"] = ""
# Start streaming in background socket.stream_runner.start_stream( self.stream_response(question), on_yield=lambda chunk: InfoEvent("chunk", chunk), on_done=InfoEvent("done"), on_error=lambda e: InfoEvent("error", str(e)), )
async def stream_response(self, question: str): """Async generator that yields response chunks.""" async for chunk in some_llm_api.stream(question): yield chunk
@info("chunk") async def handle_chunk(self, event: InfoEvent, socket): socket.context["response"] += event.payload
@info("done") async def handle_done(self, event: InfoEvent, socket): socket.context["streaming"] = FalseThe start_stream method returns a task ID that can be used to cancel the stream:
@event("ask")async def handle_ask(self, socket, question: str): task_id = socket.stream_runner.start_stream(...) socket.context["current_task"] = task_id
@event("cancel")async def handle_cancel(self, socket): socket.stream_runner.cancel_stream(socket.context["current_task"])