Skip to content
djust
Appearance
Mode

Server Actions (@action and dj-form-pending)

New in v0.8.0. Two paired primitives that close the React-19-Server-Actions gap with zero boilerplate per handler.

PrimitiveLayerWhat it gives you
@action decoratorserver{pending, error, result} for the handler, automatically injected into the template under the action's name.
dj-form-pending attributeclientDeclaratively hide/show/disable any element inside a <form dj-submit> while the submit is in-flight — no prop drilling, no JS.

dj-form-pending covers the in-flight client UX during the network round-trip; @action covers the post-completion server state after the handler returns. Together they give you React 19 form ergonomics without writing a single state-management line.


@action — Server Actions

Decorate a LiveView method with @action:

from djust import LiveView, action


class TodoView(LiveView):
    todos = state(default_factory=list)

    @action
    def create_todo(self, title: str = "", **kwargs):
        if not title:
            raise ValueError("Title is required")
        todo = Todo.objects.create(title=title, user=self.request.user)
        self.todos.append({"id": todo.id, "title": todo.title})
        return {"created": todo.id}

The framework auto-populates self._action_state["create_todo"] across the handler's lifecycle:

Phase_action_state["create_todo"]
Before handler runs{pending: True, error: None, result: None}
On return value{pending: False, error: None, result: value}
On raise exc (re-raised){pending: False, error: "<message>", result: None}

Every action's state is then injected into the template context under its method name, so you can read it directly:

{% if create_todo.pending %}
    <p>Saving…</p>
{% elif create_todo.error %}
    <div class="error">{{ create_todo.error }}</div>
{% elif create_todo.result %}
    <div class="success">Todo {{ create_todo.result.created }} created!</div>
{% endif %}

How @action differs from @event_handler

Every @action is also an @event_handler underneath — same dispatch path, same parameter coercion, same @permission_required / @rate_limit semantics. The only addition is the action-state tracking layer. Use the bare @event_handler when you don't care about post-completion state; use @action when the template needs to react to it.

Both decorator forms work

@action
def create_todo(self, **kwargs): ...

@action(description="Create a new todo item")
def create_todo(self, **kwargs): ...

The optional description= is metadata — surfaced via is_action(func) for tooling that introspects the view (e.g. the djust MCP server).

Retry semantics

Re-running an action resets its state. A failed run followed by a successful run shows only the success; a successful run followed by a failure shows only the error. The template never sees stale result alongside fresh error (or vice versa).

Name collisions

If a @action's method name happens to match a public class attribute, the action's state wins in the template context — actions are always the canonical reading of their name. This is a deliberate feature, not a bug; it lets you use natural names like save or delete without reserving them on the view class.


dj-form-pending — declarative pending UI

Mark any element nested inside a <form dj-submit> with dj-form-pending and it reacts automatically while the submit is in-flight:

<form dj-submit="create_todo">
    <input name="title" placeholder="What needs doing?">

    <button type="submit">
        <span dj-form-pending="hide">Add</span>
        <span dj-form-pending="show">Saving…</span>
    </button>

    <button type="button" dj-form-pending="disabled" dj-click="cancel">
        Cancel
    </button>
</form>

Three modes:

ModeBehavior
dj-form-pending="hide"Element is hidden via the hidden attribute while pending. Use for an idle label that disappears during submit.
dj-form-pending="show"Element is hidden by default, visible while pending. Use for a spinner or "Saving…" text.
dj-form-pending="disabled"disabled = true while pending; original disabled state restored on resolve. User-disabled elements stay disabled (the helper tracks pre-pending state in data-djust-form-pending-was-disabled).

Unknown modes are silently ignored — forward-compatible if more modes are added in a future release.

CSS-only hooks via data-djust-form-pending

The form itself gets data-djust-form-pending="true" while pending, so CSS selectors can react without any JS:

form[data-djust-form-pending] {
    cursor: progress;
}

form[data-djust-form-pending] .spinner {
    display: inline-block;
}

form:not([data-djust-form-pending]) .spinner {
    display: none;
}

Scope isolation

Only [dj-form-pending] descendants of the actually-submitting form react. If you have multiple <form dj-submit> forms on the same page, they don't interfere — each form's pending state is scoped to itself.

Error-path cleanup

The pending flag is set BEFORE the network round-trip and cleared in a finally block so it always resolves — even when the handler raises. You'll never see a stuck "Saving…" state because of an unhandled exception.


Putting them together

The canonical pattern: dj-form-pending for the in-flight UX, @action for the post-completion state, both reading the same handler:

class TodoView(LiveView):
    todos = state(default_factory=list)

    @action
    def create_todo(self, title: str = "", **kwargs):
        if not title:
            raise ValueError("Title cannot be empty")
        todo = Todo.objects.create(title=title, user=self.request.user)
        self.todos.append({"id": todo.id, "title": todo.title})
        return {"id": todo.id}
<form dj-submit="create_todo">
    <input name="title" placeholder="What needs doing?">

    <button type="submit">
        <span dj-form-pending="hide">Add</span>
        <span dj-form-pending="show">Saving…</span>
    </button>

    {# Post-completion state — read after the handler returns. #}
    {% if create_todo.error %}
        <p class="error">{{ create_todo.error }}</p>
    {% elif create_todo.result %}
        <p class="success">Added!</p>
    {% endif %}
</form>

<ul>
    {% for todo in todos %}
        <li>{{ todo.title }}</li>
    {% endfor %}
</ul>

The user types a title, clicks Add. During the round-trip the button text flips to "Saving…" (dj-form-pending). On success the new todo appears in the list and "Added!" shows below (create_todo.result). On failure (empty title) the "Saving…" clears and the error renders (create_todo.error).

Total client JS authored: zero. Total server state code: zero.


Decorator / attributeWhen to use
@event_handlerMost handlers. Triggers a re-render; no auto-tracked state.
@event_handler(expose_api=True)Handler that's also callable over HTTP for mobile / S2S / AI agents.
@server_functionSame-origin browser RPC with no re-render. Use for typeahead / validation.
@actionForm submission where the template needs to react to pending / error / result.
dj-loading="event_name"Per-event loading scope outside a form context.
dj-form-pendingPer-form pending UI without naming the event. Lives inside a <form dj-submit>.

Tests

@action is covered by 18 regression tests in tests/test_action_decorator.py. dj-form-pending is covered by 8 JSDOM tests in tests/js/dj-form-pending.test.js covering data-djust-form-pending toggle, mode handling, user-disabled preservation, scope isolation, error-path cleanup, and forward- compat for unknown modes.

See also

  • Server Functions — for browser RPC without re-render or auto-tracked state.
  • FormsFormMixin / as_live_field() for binding Django forms.
  • Events (core concepts) — the broader event-binding model that @action extends.