Decorators API Reference
On this page
@optimisticis INERT. It records handler metadata that nothing in the shipped client reads, so applying it changes nothing at runtime — a bare@optimisticdeclares no DOM change for a client to apply. Examples below that use it still work, but without the optimistic-update behaviour they describe. Tracked in issue #2699.
@debounceand@throttleARE implemented (#2656) — the client gate isstatic/djust/src/05-handler-rate-limit.js, configured from the mount frame'shandler_config.
from djust.decorators import (
event_handler,
debounce,
throttle,
optimistic,
cache,
client_state,
permission_required,
rate_limit,
background,
server_function,
)
The module also exports reactive, state, computed and on_mount, which are covered in their own guides.
@event_handler
Mark a method as callable from the client. Required on all event handlers — djust blocks any unmarked method for security.
@event_handler(params=None, description="", coerce_types=True, expose_api=False, serialize=None)
Parameters:
params(list[str], optional) — Explicit list of allowed parameter names. Defaults to auto-extraction from the function signature.description(str) — Human-readable description shown in the debug panel. Defaults to the method docstring.coerce_types(bool, defaultTrue) — Automatically coerce string values fromdata-*attributes to the expected types based on type hints ("5"→5forint).expose_api(bool, defaultFalse) — Also expose the handler as an HTTP API endpoint atPOST /djust/api/<view_slug>/<handler_name>/, with the same validation, permissions and rate limiting. On that transport the handler's return value is the response body.serialize(callable or method name, optional) — Override the HTTP API response shape. HTTP only; requiresexpose_api=True(otherwiseTypeErrorat decoration time).
Usage:
# Simple — no arguments
@event_handler()
def increment(self, **kwargs):
self.count += 1
# With type coercion (item_id="5" → item_id=5)
@event_handler()
def delete(self, item_id: int = 0, **kwargs):
Item.objects.filter(pk=item_id).delete()
# Input/change: parameter must be named 'value'
@event_handler()
def search(self, value: str = "", **kwargs):
self.query = value
# Form submit: named fields arrive as kwargs
@event_handler()
def save_form(self, name="", email="", **kwargs):
User.objects.create(name=name, email=email)
# Disable type coercion to receive raw strings
@event_handler(coerce_types=False)
def raw_handler(self, value: str = "", **kwargs):
# value is always a string, not coerced
pass
Rules:
- Always accept
**kwargs— djust passes extra metadata - Always provide default values for all parameters
valueis the magic parameter name fordj-inputanddj-changeeventsdata-item-idbecomesitem_id(kebab-case → snake_case)
@debounce
Debounce the handler on the client. The browser delays the SEND until wait seconds after the last event, so a burst of keystrokes reaches the server as one event carrying the last payload. Implemented in static/djust/src/05-handler-rate-limit.js; the config rides the mount frame as handler_config.
@debounce(wait=0.3, max_wait=None)
Parameters:
wait(float) — Seconds to wait after the last event before firing. Default0.3.max_wait(float | None) — Upper bound on the total delay, measured from the FIRST event of the burst, so a user who never pauses still gets a send. DefaultNone(unlimited).
Usage:
@event_handler()
@debounce(wait=0.5)
def search(self, value: str = "", **kwargs):
"""Fires 500ms after the user stops typing."""
self.results = Product.objects.filter(name__icontains=value)
@event_handler()
@debounce(wait=0.3, max_wait=2.0)
def autosave(self, content: str = "", **kwargs):
"""Fires 300ms after last change, but always fires within 2 seconds."""
self.draft = content
By convention it is applied inside @event_handler() (closer to the function); since it only records metadata, the order does not change its behaviour.
@throttle
Cap how often the handler is SENT — at most once per interval. With both edges on (the default) the first event of a window goes immediately and everything inside the window collapses into one trailing send carrying the last payload. Useful for scroll, resize, or mouse-move events. When a handler carries both @debounce and @throttle, @debounce wins.
@throttle(interval=0.1, leading=True, trailing=True)
Parameters:
interval(float) — Minimum seconds between calls. Default0.1.leading(bool) — Fire on the first event. DefaultTrue.trailing(bool) — Fire on the last event after the interval. DefaultTrue.
Usage:
@event_handler()
@throttle(interval=0.1)
def on_scroll(self, position: int = 0, **kwargs):
"""Fires at most 10 times/second."""
self.scroll_pos = position
@optimistic
INERT (#2699): records metadata only. No optimistic UI update and no rollback occur; the handler behaves exactly like an undecorated one.
@optimistic
No arguments — apply directly.
Usage:
@event_handler()
@optimistic
def toggle_like(self, item_id: int = 0, **kwargs):
"""Behaves like an undecorated handler today."""
item = next(i for i in self.items if i["id"] == item_id)
item["liked"] = not item["liked"]
@cache
Cache handler responses client-side. The response is stored in the browser indexed by the specified key parameters.
@cache(ttl=60, key_params=None)
Parameters:
ttl(int) — Cache lifetime in seconds. Default60.key_params(list[str] | None) — Parameter names to use as cache key. Default[](caches by handler name only).
Usage:
@event_handler()
@cache(ttl=300, key_params=["value"])
def search(self, value: str = "", **kwargs):
"""Results for "laptop" are cached for 5 minutes."""
self.results = Product.objects.filter(name__icontains=value)[:20]
@client_state
INERT (#2680) — stamps metadata nothing in the shipped client reads, so a decorated handler behaves exactly like an undecorated one. The
StateBusit named was deleted in #2680.
Share state via a client-side pub/sub bus. When specified keys change, other components subscribed to those keys update automatically.
@client_state(keys)
Parameters:
keys(list[str]) — Attribute names to publish after this handler runs.
Usage:
@event_handler()
@client_state(keys=["filter", "sort"])
def update_filter(self, filter: str = "all", **kwargs):
self.filter = filter
# Other components listening for 'filter' update automatically
Loading states
Loading indicators are not a decorator. They are declarative attributes on the element that fires the event, so they need no handler code at all:
<button dj-click="save" dj-loading.disable>Save</button>
<div dj-loading.show style="display:none">Saving...</div>
See Loading States & Background Work for the
full set — .show, .hide, .disable, .class, and .for scoping.
@permission_required
Check Django permissions before the handler executes. Returns an error if the user lacks the required permission(s).
@permission_required(perm)
Parameters:
perm(str | list[str]) — Django permission string(s) (e.g.,"myapp.can_delete").
Usage:
@event_handler()
@permission_required("myapp.can_delete")
def delete_item(self, item_id: int = 0, **kwargs):
Item.objects.filter(pk=item_id).delete()
# Require multiple permissions (all must be satisfied)
@event_handler()
@permission_required(["myapp.can_edit", "myapp.can_publish"])
def publish(self, **kwargs):
self.item.published = True
self.item.save()
@background
Run the entire event handler in a background thread after flushing current state. The view re-renders and sends patches when the handler completes.
@background
No arguments — apply directly.
Usage:
from djust.decorators import background
@event_handler
@background
def generate_content(self, prompt: str = "", **kwargs):
"""Entire method runs in background thread."""
try:
self.content = call_llm(prompt) # Long-running operation
except Exception as e:
self.error = str(e)
finally:
self.generating = False
How it works:
- Current view state is flushed to client
- Handler executes in background thread
- View re-renders and sends patches when handler completes
Because the whole body runs in the background, state you set inside it is rendered only once, when the body finishes. A self.generating = True at the top of the body never reaches the client. To show progress, use dj-loading.* attributes on the triggering element, or set the flag in a normal handler and then call self.start_async(...) (see below).
Known issue: #2963. The server never sends
async_pendingfor@backgroundtasks, so adj-loading.*indicator ends when the handler returns, not when the background work finishes.
Task naming and cancellation:
The task name is automatically set to the handler's function name. Cancel via self.cancel_async(name):
@event_handler
@background
def long_operation(self, **kwargs):
# Task name is "long_operation"
...
@event_handler
def cancel_operation(self, **kwargs):
self.cancel_async("long_operation")
Combining with other decorators:
@event_handler
@debounce(wait=0.5)
@background
def auto_save(self, **kwargs):
# Debounced and runs in background
self.save_draft()
When to use @background vs start_async():
- Use
@backgroundwhen the entire handler should run in background - Use
self.start_async(callback)when you need to update state before starting background work, or need multiple concurrent tasks with different names
See also: Loading States & Background Work guide
@rate_limit
Rate-limit a handler on the server with a per-handler token bucket. When the limit is exceeded, the event is dropped and the client is warned.
@rate_limit(rate=10, burst=5)
Parameters:
rate(float) — Tokens per second (sustained rate). Default10.burst(int) — Maximum burst capacity. Default5.
Usage:
@rate_limit(rate=5, burst=3)
@event_handler()
def expensive_operation(self, **kwargs):
...
@server_function
Mark a method as a same-origin browser RPC target. The client calls it with await djust.call('<view_slug>', '<method_name>', {params}) and receives its JSON-serialized return value, with no re-render. It cannot be combined with @event_handler.
@server_function
def search(self, q: str = "", **kwargs) -> list[dict]:
return [{"id": p.id, "name": p.name} for p in Product.objects.filter(name__icontains=q)[:10]]
Decorator Composition
Decorators compose — apply multiple to one handler:
@event_handler() # registers the handler
@debounce(0.5) # wait for typing to stop
@cache(ttl=60) # return cached result if available
def search(self, value: str = "", **kwargs):
self.results = Product.objects.filter(name__icontains=value)
@debounce, @throttle and @cache only record metadata that the client reads, so their order is not significant. @optimistic is inert. @background is the only one that wraps the handler at runtime.
See Also
- Events guide — event binding in templates
- State Management — higher-level patterns
- LiveView API