Live updates with partial rendering#
Goal#
This part makes the Notes index update in place. The list of notes becomes a zone, a search box filters it as the visitor types with no handler at all, and creating a note refreshes the list without a full reload. Every behaviour falls back to the page cycle from Forms and actions when JavaScript is off, so the same code serves both paths.
Prerequisites#
You have finished Testing and autoreload. The Notes application creates, edits, and deletes notes through registered actions.
The layout from Components and static assets already pulls {% collect_scripts %} into the bottom of <body>, and the static pipeline injects the client runtime through that tag.
There is nothing new to install.
Partial rendering layers on top of the POST, redirect, GET flow the framework already serves, covered in depth in Partial rendering.
Walkthrough#
Wrap the note list in a zone#
A {% zone %} block marks a slice of a template the server can re-render on its own.
A zone is an optimisation rather than required markup.
The page renders identically without one, and naming the region lets a response carry only that slice instead of the whole document.
Wrap the list in notes/pages/template.djx and give each row a stable key.
{% zone "note-list" tag="ul" %}
{% for note in notes %}
<li data-next-key="{{ note.id }}">{% component "note_card" %}</li>
{% endfor %}
{% endzone %}
The tag="ul" argument makes the zone the <ul> element itself.
A wrapper <div> inside a <ul> would be dropped by the HTML parser, so a list zone names the list element directly.
Each data-next-key keeps a row stable when the list re-renders, so the morph reuses the node a row already owns instead of rebuilding it.
Reload / and confirm the list looks unchanged.
The zone is invisible until something targets it.
Filter the list as you type#
Add a search box above the list.
It is a plain GET form that names the zone it updates.
<form method="get" action="{% url 'next:page_' %}"
data-next-target="note-list"
data-next-trigger="input" data-next-debounce="300">
<input type="search" name="q" value="{{ query }}" placeholder="Filter notes">
</form>
data-next-target names the zone to update.
data-next-trigger="input" auto-submits the form as the visitor types.
data-next-debounce="300" waits 300 ms after the last keystroke before sending one request.
The page reads the query and filters the list.
Update notes/pages/page.py so the notes context honours q, and publish the current query for the input value.
from django.http import HttpRequest
from notes.models import Note
from next import context
@context("site_name", inherit_context=True)
def site_name() -> str:
return "Notes"
@context("tagline", inherit_context=True)
def tagline() -> str:
return "A small tutorial application."
@context("note_count", inherit_context=True)
def note_count() -> int:
return Note.objects.count()
@context("query")
def query(request: HttpRequest) -> str:
return request.GET.get("q", "").strip()
@context("notes")
def recent_notes(request: HttpRequest) -> list[Note]:
q = request.GET.get("q", "").strip()
qs = Note.objects.all()
if q:
qs = qs.filter(title__icontains=q)
return list(qs)
Both callables read request.GET the same way, so the zone fetch and the full page agree on the filter.
The query context only feeds the input value, and the notes context drives the list.
See Context for how a page publishes named values.
The hand-parsed request.GET keeps the example explicit.
The DQuery marker from next.urls reads the same query parameter declaratively, see Dependency injection.
There is no handler and no JavaScript.
Typing gro debounces, then sends one zone GET.
GET /?q=gro HTTP/1.1
X-Next-Request: 1
X-Next-Zone: note-list
The server re-renders only the note-list zone and answers with a single morph operation.
{
"version": "9f3c2e1b",
"ops": [
{"op": "morph", "target": {"zone": "note-list"},
"html": "<ul data-next-zone=\"note-list\">…matching notes…</ul>"}
],
"assets": [
{"kind": "css", "url": "…/note_card/component.css", "load": "link"},
{"kind": "js", "url": "…/note_card/component.js", "load": "script"}
],
"form": null
}
The envelope also ships the co-located assets of the zone body, here the note_card styles and script, and adds a context op with the js-context delta when one applies.
The runtime syncs the address bar with history.replaceState, so /?q=gro stays shareable.
A new keystroke aborts an in-flight request, and a stale response that arrives after a fresher one is discarded.
Without the runtime the same form is a plain GET that reloads the whole page with the filtered list.
The provider reads request.GET either way, so the zone fetch reuses the exact query parsing the full page uses.
Create a note in place#
The create form from Forms and actions already posts through a registered action.
Teach its handler to answer a partial request with a patch instead of a redirect.
Update the CreateNoteForm class in notes/forms.py and merge the new imports, keeping DeleteNoteForm and its imports in place.
from django.http import HttpRequest, HttpResponse
from notes.models import Note
from next.forms import ModelForm
from next.partial import Patches, is_partial_request
class CreateNoteForm(ModelForm):
class Meta:
model = Note
fields = ("title", "body")
def on_valid(self, request: HttpRequest) -> HttpResponse:
if is_partial_request(request):
self.save()
return Patches(request).morph(zone="note-list").response()
return super().on_valid(request)
is_partial_request is True only when the runtime made the submission.
On that path the handler saves the note and returns a morph of the note-list zone, which re-renders the list from the notes context with the new note included.
On the no-JavaScript path super().on_valid keeps the inherited behaviour.
It saves and redirects to origin, and the reload shows the new note.
The form tag itself does not change.
{% form "create_note_form" %}
<label>Title {{ form.title }}</label>
<label>Body {{ form.body }}</label>
<button type="submit">Create</button>
{% endform %}
The form carries no zone= argument, so a validation failure still re-renders the form in place with its errors, the default re-render from Forms and actions.
The handler drives the list update explicitly, only on success and only for a partial request.
The morph keeps the caret in the title field, so a visitor can add several notes in a row without the page jumping.
Submit a note with a title and watch it appear at the top of the list with no reload. Submit with an empty title and the form re-renders its error in place, the list untouched.
How it degrades#
Turn JavaScript off and exercise the same page.
The search box submits a full GET that reloads the filtered list.
The create form posts and redirects to origin, and the reloaded page lists the new note.
The handler reaches its super().on_valid branch because is_partial_request is False without the runtime.
The page that works without the runtime keeps working with it, and gains the partial behaviour for free.
Checkpoint#
The Notes index is live. Filtering re-renders one zone as the visitor types, creating a note refreshes the list in place, and both paths still work with JavaScript off.
notes/
forms.py # CreateNoteForm.on_valid branches on is_partial_request
pages/
page.py # query context, notes context filters by q
template.djx # search form, {% zone "note-list" %}
No new models, no new URLs, and no client code. The behaviour rides the action dispatch and the file router the application already had.
Common pitfalls#
- The filter reloads the whole page instead of swapping the list.
Check that
data-next-targetnames the same string as the{% zone %}tag, and that{% collect_scripts %}sits at the bottom of the layout<body>so the runtime loads.- Creating a note redirects instead of updating in place.
The handler only returns a patch inside the
is_partial_requestbranch. A submission from a page without the runtime takes thesuper().on_validredirect by design.- The new note appears twice for a moment.
Confirm each
<li>carriesdata-next-key="{{ note.id }}". The key lets the morph match a row to the node it already owns instead of duplicating it.
See Troubleshooting for the full catalog of errors and fixes.
Next steps#
The Notes application is complete and live.
See also
What to read next lists where to go next, by topic. Partial rendering covers zones, patches, modals, lazy zones, and the SSE bridge. Partial rendering by scenario walks six more partial-rendering tasks from markup to handler.