This guide explains how to write effective event handlers in djust LiveView applications, including parameter naming conventions, type validation, and debugging tips.
- The **kwargs Requirement
- Parameter Naming Convention
- Inline Handler Arguments
- The @event_handler Decorator
- Async Event Handlers
- Type Hints and Validation
- Public vs Private Variables
- Error Handling
- Common Patterns
- Debugging Tips
- Security
Every event handler must accept **kwargs. The djust client sends metadata parameters alongside your explicit arguments (such as _targetElement identifying the DOM element that triggered the event). Without **kwargs, your handler will reject these extra parameters with a validation error.
# Wrong -- will raise a validation error at runtime
@event_handler()
def increment(self):
self.count += 1
# Correct -- accepts client metadata
@event_handler()
def increment(self, **kwargs):
self.count += 1This applies to all event handlers, including those with explicit parameters:
@event_handler()
def search(self, value: str = "", **kwargs):
self.query = value
@event_handler()
def delete_item(self, item_id: int = 0, **kwargs):
Item.objects.filter(id=item_id).delete()Form elements (<input>, <select>, <textarea>) send their value using the value parameter when using dj-input or dj-change events.
✅ Correct:
@event_handler()
def search(self, value: str = "", **kwargs):
"""value matches what dj-input/dj-change sends"""
self.search_query = value
self._refresh_properties()
@event_handler()
def filter_by_status(self, value: str = "all", **kwargs):
"""value parameter for select/dropdown changes"""
self.filter_status = value
self._refresh_properties()❌ Wrong:
@event_handler()
def search(self, query: str = "", **kwargs):
"""Won't work! dj-input sends 'value', not 'query'"""
self.search_query = query # Will always be "" (default)When multiple form fields share one dj-change or dj-input handler, the _target parameter identifies which field triggered the event (the element's name attribute, falling back to id, then null):
@event_handler()
def validate(self, value: str = "", _target: str = "", **kwargs):
"""_target tells you which field triggered the event"""
if _target == "email":
self.email_error = "" if "@" in value else "Invalid email"
elif _target == "username":
self.username_error = "" if len(value) >= 3 else "Too short"For dj-submit, _target is the submitter button's name (if available). Matches Phoenix LiveView's _target convention.
For custom events (not form inputs), pass parameters via data-dj-* attributes.
The data-dj- prefix is stripped automatically, so data-dj-property-id maps to
the property_id Python parameter:
@event_handler()
def delete_property(self, property_id: int, **kwargs):
"""property_id comes from data-dj-property-id attribute"""
property = Property.objects.get(id=property_id)
property.delete()Template usage:
<button dj-click="delete_property" data-dj-property-id="{{ property.id }}">
Delete
</button>Note: Plain
data-*attributes (without thedj-prefix) also work —data-property-idmaps toproperty_idtoo. Thedata-dj-*convention is recommended for consistency withdj-click,dj-change, and other djust attributes, and to avoid collisions with non-djust libraries.
You can pass arguments directly in the handler attribute using function-call syntax. This provides a cleaner alternative to data-* attributes for simple values.
<!-- Instead of using data-* attributes -->
<button dj-click="set_period" data-value="month">30 Days</button>
<!-- You can use inline arguments -->
<button dj-click="set_period('month')">30 Days</button>Both approaches call the same handler:
@event_handler()
def set_period(self, value: str, **kwargs):
"""Set the time period filter"""
self.period = value
self._refresh_data()Inline arguments are automatically parsed into their appropriate types:
| Syntax | Parsed Value | Python Type |
|---|---|---|
'string' or "string" |
"string" |
str |
123 |
123 |
int |
45.67 |
45.67 |
float |
true / false |
True / False |
bool |
null |
None |
NoneType |
Limitation: Only primitive types are supported. Arrays and objects (e.g., handler([1,2,3]) or handler({key: 'value'})) are not supported. For complex data, use data-* attributes with JSON strings and parse them in your handler.
Pass multiple arguments separated by commas:
<button dj-click="sort_by('name', true)">Sort by Name (Ascending)</button>
<button dj-click="filter('status', 'active', 1)">Active Items</button>@event_handler()
def sort_by(self, field: str, ascending: bool = True, **kwargs):
"""Sort items by field"""
self.sort_field = field
self.sort_ascending = ascending
@event_handler()
def filter(self, field: str, value: str, page: int = 1, **kwargs):
"""Filter items"""
self.filters[field] = value
self.current_page = pageYou can combine inline arguments with data-dj-* attributes. Inline arguments map to parameters by position, while data-dj-* attributes map by name:
<button dj-click="update_item('delete')"
data-dj-item-id="{{ item.id }}"
data-dj-confirm="true">
Delete Item
</button>@event_handler()
def update_item(self, action: str, item_id: int = 0, confirm: bool = False, **kwargs):
"""
action comes from inline arg ('delete')
item_id comes from data-dj-item-id
confirm comes from data-dj-confirm
"""
if action == 'delete' and confirm:
Item.objects.filter(id=item_id).delete()Note: If both an inline argument and a data-* attribute provide the same parameter, the inline argument takes precedence.
| Approach | Best For |
|---|---|
| Inline arguments | Static values known at template render time |
| data- attributes* | Dynamic values from template context (e.g., {{ item.id }}) |
| Combined | Action type as inline arg + entity ID from context |
<div class="tabs">
<button dj-click="select_tab(0)">Overview</button>
<button dj-click="select_tab(1)">Details</button>
<button dj-click="select_tab(2)">Settings</button>
</div><div class="period-selector">
<button dj-click="set_period('day')">Today</button>
<button dj-click="set_period('week')">This Week</button>
<button dj-click="set_period('month')">This Month</button>
<button dj-click="set_period('year')">This Year</button>
</div>{% for item in items %}
<div class="item-row">
<span>{{ item.name }}</span>
<button dj-click="item_action('edit')" data-dj-item-id="{{ item.id }}">Edit</button>
<button dj-click="item_action('delete')" data-dj-item-id="{{ item.id }}">Delete</button>
</div>
{% endfor %}Mark all event handlers with @event_handler() to enable:
- Parameter introspection
- Debug panel discovery
- IDE autocomplete support
- Automatic validation
from djust.decorators import event_handler
@event_handler()
def my_handler(self, value: str = ""):
"""This description appears in the debug panel"""
self.my_value = valueStack decorators in order (top to bottom):
from djust.decorators import event_handler, debounce, optimistic
@event_handler()
@debounce(wait=0.5)
def search(self, value: str = "", **kwargs):
"""Debounced search - waits 500ms after user stops typing"""
self.search_query = value
self._refresh_results()
@event_handler()
@optimistic
def delete_item(self, item_id: int):
"""Optimistic delete - UI updates before server confirms"""
Item.objects.filter(id=item_id).delete()@event_handler(description="Sort properties by selected field")
def sort_properties(self, value: str = "name"):
"""Explicit description overrides docstring"""
self.sort_by = value
self._refresh_properties()Event handlers can be defined as either sync or async functions. Use async handlers when you need to perform non-blocking I/O operations.
@event_handler()
async def fetch_external_data(self, item_id: int):
"""Async handler for non-blocking external API calls"""
async with aiohttp.ClientSession() as session:
async with session.get(f"https://api.example.com/items/{item_id}") as response:
self.item_data = await response.json()| Use Case | Sync or Async |
|---|---|
| Database queries (Django ORM) | Sync (use sync_to_async inside if needed) |
| External API calls | Async |
File I/O with aiofiles |
Async |
| Simple state updates | Sync |
You can have both sync and async handlers in the same LiveView:
class MyView(LiveView):
@event_handler()
def increment(self):
"""Sync handler for simple state update"""
self.count += 1
@event_handler()
async def fetch_weather(self, city: str):
"""Async handler for external API"""
self.weather = await get_weather_async(city)Template data-dj-* attributes always send values as strings. djust automatically coerces these strings to the expected types based on your type hints:
@event_handler()
def update_quantity(self, item_id: int, quantity: int = 1):
"""
item_id and quantity are automatically coerced from strings.
Template sends: data-dj-item-id="123" data-dj-quantity="5"
Handler receives: item_id=123, quantity=5 (as integers)
"""
self.items[item_id].quantity = quantityThis means you can use proper type hints and djust handles the conversion automatically.
| Type | String Input | Coerced Value |
|---|---|---|
int |
"123", "-45" |
123, -45 |
float |
"3.14", "-273.15" |
3.14, -273.15 |
bool |
"true", "1", "yes", "on" |
True |
bool |
"false", "0", "no", "off", "" |
False |
Decimal |
"123.45" |
Decimal("123.45") |
UUID |
"550e8400-e29b-..." |
UUID("550e8400-...") |
list |
"a,b,c" |
["a", "b", "c"] |
List[int] |
"1,2,3" |
[1, 2, 3] |
Optional[int] |
"42" |
42 |
@event_handler()
def example_types(
self,
name: str = "", # String (no conversion needed)
count: int = 0, # Coerced from "123" to 123
price: float = 0.0, # Coerced from "9.99" to 9.99
active: bool = False, # Coerced from "true" to True
tags: list = [], # Coerced from "a,b,c" to ["a","b","c"]
**kwargs # Accept any additional params
):
passIf you need raw string values, disable coercion with coerce_types=False:
@event_handler(coerce_types=False)
def raw_handler(self, value: str = "", **kwargs):
"""Receives raw string values from template, no coercion"""
# value is exactly what the template sent
passUse default values to make parameters optional:
@event_handler()
def filter_items(
self,
category: str = "all", # Optional with default
min_price: float = 0.0, # Optional with default
max_price: float = 9999.99, # Optional with default
**kwargs
):
"""All parameters are optional with sensible defaults"""
queryset = Item.objects.all()
if category != "all":
queryset = queryset.filter(category=category)
queryset = queryset.filter(price__gte=min_price, price__lte=max_price)
self.items = querysetOmit default values to make parameters required:
@event_handler()
def create_item(self, name: str, price: float):
"""
name and price are REQUIRED.
Calling without them will raise a validation error.
"""
Item.objects.create(name=name, price=price)djust uses a naming convention to distinguish public and private instance variables:
_private- Not exposed to template contextpublic- Auto-exposed to template context
The JIT Serialization Pattern (see docs/JIT_SERIALIZATION_PATTERN.md) relies on this convention:
def mount(self, request):
# Private - internal state, not in template
self._properties = Property.objects.all()
# Public - auto-exposed to template
self.properties = None # Set in get_context_data()
self.search_query = ""
self.filter_status = "all"
def _refresh_properties(self):
"""Private helper method - build QuerySet"""
items = Property.objects.all()
if self.search_query:
items = items.filter(name__icontains=self.search_query)
if self.filter_status != "all":
items = items.filter(status=self.filter_status)
self._properties = items # Store in PRIVATE variable
def get_context_data(self, **kwargs):
"""Expose for JIT serialization"""
self.properties = self._properties # Assign to PUBLIC variable
context = super().get_context_data(**kwargs) # Triggers Rust JIT
return context- Clarity: Easy to see what's exposed to templates
- Performance: JIT serialization only processes public variables
- Debugging: Debug panel only shows public variables
- Safety: Private state can't be accidentally accessed in templates
djust validates parameters strictly by default:
- Missing required parameters → Validation error
- Unexpected parameters → Validation error
- Wrong types → Validation error
Example validation error:
Handler 'search' received unexpected parameters: ['query']. Expected: ['value']
Open browser DevTools → Console:
[LiveView] Parameter validation failed: Handler 'search' received unexpected parameters: ['query']. Expected: ['value']- Open debug panel (
Ctrl+Shift+D) - Go to Event History tab
- Error events are highlighted in red
- Click to see validation details
ERROR Handler 'search' missing required parameters: ['name']Accept any parameters with **kwargs:
@event_handler()
def flexible_handler(self, value: str = "", **kwargs):
"""
Accepts any parameters beyond 'value'.
Useful when:
- Parameters are optional/dynamic
- Template might send data-* attributes
- You want to future-proof the handler
"""
self.value = value
# Access optional parameters
if 'extra_data' in kwargs:
self.extra_data = kwargs['extra_data']@event_handler()
@debounce(wait=0.5)
def search(self, value: str = "", **kwargs):
"""
Wait 500ms after user stops typing before searching.
Reduces server load and improves UX.
"""
self.search_query = value
self._refresh_results()Template:
<input type="text"
dj-input="search"
value="{{ search_query }}"
placeholder="Search...">@event_handler()
def filter_by_status(self, value: str = "all", **kwargs):
"""Filter items by status dropdown selection"""
self.filter_status = value
self._refresh_results()Template:
<select dj-change="filter_by_status">
<option value="all" {% if filter_status == "all" %}selected{% endif %}>All</option>
<option value="active" {% if filter_status == "active" %}selected{% endif %}>Active</option>
<option value="inactive" {% if filter_status == "inactive" %}selected{% endif %}>Inactive</option>
</select>@event_handler()
def show_delete_confirmation(self, item_id: int):
"""Show confirmation dialog before deleting"""
self.delete_item_id = item_id
self.show_delete_modal = True
@event_handler()
@optimistic
def confirm_delete(self):
"""Actually delete the item after confirmation"""
if self.delete_item_id:
Item.objects.filter(id=self.delete_item_id).delete()
self.delete_item_id = None
self.show_delete_modal = False
@event_handler()
def cancel_delete(self):
"""Cancel deletion"""
self.delete_item_id = None
self.show_delete_modal = False@event_handler()
def save_property(
self,
name: str = "",
address: str = "",
price: float = 0.0,
**kwargs
):
"""Save property from form submission"""
if self.edit_property_id:
# Update existing
property = Property.objects.get(id=self.edit_property_id)
property.name = name
property.address = address
property.price = price
property.save()
else:
# Create new
Property.objects.create(
name=name,
address=address,
price=price
)
self.edit_property_id = None
self.show_form = False
self._refresh_properties()Template:
<form dj-submit="save_property">
<input type="text" name="name" value="{{ property.name }}">
<input type="text" name="address" value="{{ property.address }}">
<input type="number" name="price" value="{{ property.price }}">
<button type="submit">Save</button>
</form>- Open: Press
Ctrl+Shift+Dor click the 🐞 button - Check Event Handlers: See all registered handlers with their expected parameters
- Monitor Events: Watch events as they fire with actual parameters
- Inspect Errors: Red-highlighted events show validation errors
Symptoms: Click button, nothing happens
Checklist:
- ✅ Is handler decorated with
@event_handler()? - ✅ Is event name correct in template (
dj-click="handler_name")? - ✅ Check browser console for JavaScript errors
- ✅ Check debug panel Event History for validation errors
Symptoms: Handler receives default value instead of form value
Solution: Use value parameter for form inputs (dj-input, dj-change events):
# ❌ Wrong
def search(self, query: str = ""):
pass
# ✅ Correct
def search(self, value: str = ""):
passSymptoms: Error in console: received unexpected parameters: ['extra']
Solution: Add **kwargs to accept any parameters:
# ❌ Strict - rejects unexpected params
def handler(self, value: str = ""):
pass
# ✅ Flexible - accepts extra params
def handler(self, value: str = "", **kwargs):
passSymptoms: Error: expected int, got str with hint about coercion failure
Cause: The string value can't be converted to the expected type (e.g., "abc" can't become an int).
Solution: Ensure template sends valid values:
# Template sends invalid string for int
<button dj-click="delete_item" data-dj-item-id="not_a_number">Delete</button>
# ❌ This will fail - "not_a_number" can't be coerced to int
@event_handler()
def delete_item(self, item_id: int):
pass
# ✅ Correct - template sends valid integer string
<button dj-click="delete_item" data-dj-item-id="{{ item.id }}">Delete</button>
# Handler receives item_id as int (automatically coerced)
@event_handler()
def delete_item(self, item_id: int):
Item.objects.filter(id=item_id).delete() # Works!Note: With automatic type coercion, you no longer need to manually convert types. Just use proper type hints and ensure templates send valid values.
- All event handlers have
@event_handlerdecorator - Form input handlers use
valueparameter - Type hints specified for all parameters
- Required vs optional parameters clearly distinguished
- Docstrings describe what the handler does
- Private variables start with
_ - Public variables don't start with
_ - Complex handlers split into helper methods
- Debug panel used to verify handler signatures
- Event history checked for validation errors
By default, djust runs in strict security mode: only methods decorated with @event_handler are callable via WebSocket. Undecorated methods are blocked even if they pass the event name pattern check.
from djust import LiveView
from djust.decorators import event_handler
class MyView(LiveView):
@event_handler
def increment(self):
"""Callable via WebSocket"""
self.count += 1
@event_handler(description="Search items")
def search(self, value: str = "", **kwargs):
"""Also callable via WebSocket"""
self.query = value
def mount(self, request):
"""NOT callable via WebSocket (not decorated)"""
self.count = 0
def _internal_helper(self):
"""NOT callable — underscore prefix blocked by pattern guard"""
passUse @rate_limit to protect expensive operations from abuse:
from djust.decorators import event_handler, rate_limit
class MyView(LiveView):
@rate_limit(rate=2, burst=3)
@event_handler
def generate_report(self, **kwargs):
"""Limited to 2/sec sustained, 3 burst"""
self.report = expensive_computation()In settings.py:
LIVEVIEW_CONFIG = {
# "strict" (default), "warn", or "open"
"event_security": "strict",
# Global rate limit (token bucket) and per-IP connection limits
"rate_limit": {
"rate": 100,
"burst": 20,
"max_warnings": 3,
"max_connections_per_ip": 10,
"reconnect_cooldown": 5,
},
# Max WebSocket message size in bytes (0 = no limit)
"max_message_size": 65536,
}See Security Guidelines for full details.
- Debug Panel User Guide - Interactive debugging tools
- JIT Serialization Pattern - Public/private variable usage
- State Management Decorators - @debounce, @optimistic, @cache