State Management | Django LiveView

Django LiveView maintains stateful WebSocket connections, but what happens when users navigate away or reload the page? This guide shows how to persist user state using Django sessions.

Simple State: Counter

Let's start with a simple example: a counter that increments each time you click a button and persists across page reloads.

Python handler:

from liveview import liveview_handler, send
from django.template.loader import render_to_string

@liveview_handler("increment_counter")
def increment_counter(consumer, content):
        session = consumer.scope.get("session")

        # Get current count or start at 0
        count = session.get("counter", 0)

        # Increment
        count += 1

        # Save to session
        session["counter"] = count
        session.save()

        # Send updated counter
        send(consumer, {
                "target": "#counter-display",
                "html": render_to_string("counter_display.html", {
                        "count": count
                })
        })

⚠️ Important: Always call session.save() explicitly in liveview handlers. Unlike regular HTTP views, WebSocket handlers run outside the request/response cycle, so Django's session middleware never gets the chance to save the session automatically. Using session.modified = True alone is not enough.

Initial view with pre-populated counter:

# views.py
from django.shortcuts import render

def counter_view(request):
        # Restore counter from session on page load
        count = request.session.get("counter", 0)

        return render(request, "counter.html", {
                "count": count
        })

HTML template:

<!-- counter.html -->
<div>
        <div id="counter-display">
                {% include "counter_display.html" %}
        </div>

        <button
                data-liveview-function="increment_counter"
                data-action="click->page#run">
                Increment
        </button>
</div>

<!-- counter_display.html -->
<p>Counter: <strong>{{ count }}</strong></p>

The counter persists across page reloads because:

  1. On page load, Django renders the template with count from request.session

  2. Each button click increments the counter and saves to session["counter"]

  3. When users reload the page, step 1 repeats with the saved value

Form Draft Persistence

For complex forms, you can save partial progress and restore it later. This is useful for long forms that users might abandon and return to.

Python handlers:

from liveview import liveview_handler, send
from django.template.loader import render_to_string
from datetime import datetime

@liveview_handler("autosave_form")
def autosave_form(consumer, content):
        """Auto-saves form state to session"""
        session = consumer.scope.get("session")
        form_id = content.get("form_id")
        form_data = content.get("form")

        # Store multiple form drafts
        if "form_drafts" not in session:
                session["form_drafts"] = {}

        session["form_drafts"][form_id] = {
                "data": form_data,
                "timestamp": datetime.now().isoformat()
        }
        session.save()

        send(consumer, {
                "target": "#autosave-status",
                "html": '<small class="text-success">✓ Draft saved</small>'
        })

@liveview_handler("restore_form_draft")
def restore_form_draft(consumer, content):
        """Restores a saved draft"""
        session = consumer.scope.get("session")
        form_id = content.get("form_id")

        drafts = session.get("form_drafts", {})
        draft = drafts.get(form_id)

        if draft:
                send(consumer, {
                        "action": "restore_form",
                        "form_id": form_id,
                        "form_data": draft["data"],
                        "saved_at": draft["timestamp"]
                })
        else:
                send(consumer, {
                        "action": "no_draft_found",
                        "form_id": form_id
                })

@liveview_handler("clear_form_draft")
def clear_form_draft(consumer, content):
        """Clears draft after successful submission"""
        session = consumer.scope.get("session")
        form_id = content.get("form_id")

        if "form_drafts" in session and form_id in session["form_drafts"]:
                del session["form_drafts"][form_id]
                session.save()

View with draft restoration:

# views.py
from django.shortcuts import render
from .forms import ContactForm

def contact_view(request):
        form_id = "contact_form"

        # Check for saved draft
        draft = request.session.get("form_drafts", {}).get(form_id)

        if draft:
                # Pre-populate form with draft data
                form = ContactForm(initial=draft["data"])
                has_draft = True
        else:
                form = ContactForm()
                has_draft = False

        return render(request, "contact.html", {
                "form": form,
                "has_draft": has_draft,
                "form_id": form_id
        })

HTML template with auto-save:

<div id="contact-container">
        {% if has_draft %}
        <div class="alert alert-info">
                You have a saved draft
        </div>
        {% endif %}

        <form id="{{ form_id }}">
                <input
                        type="text"
                        name="name"
                        placeholder="Name"
                        value="{{ form.name.value|default:'' }}"
                        data-liveview-function="autosave_form"
                        data-liveview-form-id="{{ form_id }}"
                        data-liveview-debounce="2000"
                        data-action="input->page#run">

                <input
                        type="email"
                        name="email"
                        placeholder="Email"
                        value="{{ form.email.value|default:'' }}"
                        data-liveview-function="autosave_form"
                        data-liveview-form-id="{{ form_id }}"
                        data-liveview-debounce="2000"
                        data-action="input->page#run">

                <textarea
                        name="message"
                        placeholder="Message"
                        data-liveview-function="autosave_form"
                        data-liveview-form-id="{{ form_id }}"
                        data-liveview-debounce="2000"
                        data-action="input->page#run">{{ form.message.value|default:'' }}</textarea>

                <div id="autosave-status"></div>

                <button
                        type="button"
                        data-liveview-function="submit_contact"
                        data-liveview-form-id="{{ form_id }}"
                        data-action="click->page#run">
                        Submit
                </button>
        </form>
</div>

Each field uses data-liveview-debounce="2000" to wait 2 seconds after the user stops typing before auto-saving. This prevents excessive WebSocket messages while still providing a responsive auto-save experience.

Handler for form submission with draft cleanup:

@liveview_handler("submit_contact")
def submit_contact(consumer, content):
        from .forms import ContactForm

        session = consumer.scope.get("session")
        form_id = content.get("form_id")
        form_data = content.get("form")

        form = ContactForm(form_data)

        if form.is_valid():
                # Process form
                contact = form.save()

                # Clear the draft
                if "form_drafts" in session and form_id in session["form_drafts"]:
                        del session["form_drafts"][form_id]
                        session.save()

                send(consumer, {
                        "target": "#contact-container",
                        "html": '<div class="alert alert-success">Message sent!</div>'
                })
        else:
                # Re-render with errors (keeping draft)
                html = render_to_string("contact_form.html", {
                        "form": form,
                        "form_id": form_id
                })

                send(consumer, {
                        "target": "#contact-container",
                        "html": html
                })

Session Configuration

Control how long drafts persist by configuring Django sessions:

# settings.py

# Session cookie age (7 days)
SESSION_COOKIE_AGE = 60 * 60 * 24 * 7

# Don't extend session on every request (saves resources)
SESSION_SAVE_EVERY_REQUEST = False

# Use database sessions for persistence
SESSION_ENGINE = 'django.contrib.sessions.backends.db'

# Or use cache for faster access
# SESSION_ENGINE = 'django.contrib.sessions.backends.cache'

SPA Navigation and Session State

If you implement SPA-style navigation using a custom navigate handler, be aware that the handler typically creates an internal HTTP client to render the destination view. This client starts with a fresh session that does not include any data saved by your liveview handlers.

The solution is to copy the WebSocket session data into the internal client's session after authenticating, so the rendered view receives the correct state.

Python handler:

from liveview import liveview_handler, send
from django.test import Client
import re

@liveview_handler("navigate")
def navigate(consumer, content):
        url = content.get("data", {}).get("data_url", "/")

        client = Client()

        if hasattr(consumer, "scope") and "user" in consumer.scope:
                user = consumer.scope["user"]
                if user and user.is_authenticated:
                        client.force_login(user)

                        # Copy session data from the WebSocket connection to the
                        # internal client so that views can read state set by
                        # liveview handlers (e.g. counters, form drafts).
                        ws_session = consumer.scope.get("session")
                        if ws_session:
                                session = client.session
                                for key, value in ws_session.items():
                                        if not key.startswith("_auth_user"):
                                                session[key] = value
                                session.save()

        response = client.get(url, follow=True)
        # ... extract and send HTML content

Without this step, any value saved with session.save() in a liveview handler will not be visible to the view rendered during SPA navigation, even though it is correctly persisted in the database.

⚠️ Note: The _auth_user keys are skipped when copying to avoid overwriting the authentication data set by force_login().