Tutorial: Build a typeahead with @server_function
In Tutorial: Build a search-as-you-type feature
we used @event_handler so every keystroke re-rendered the results
list as part of the LiveView's normal diff cycle. That's the right
choice when the suggestions are part of the page being built up.
Sometimes you want the opposite: an autocomplete dropdown attached to one field of a larger form, where typing in the field shouldn't disturb anything else the user has filled in. The whole form is already mounted with state — re-rendering it just to show a list of suggestions risks losing focus, scroll position, or in-progress input on other fields.
That's what @server_function is for: pure browser-to-Python RPC,
with no VDOM diff and no re-render. The server returns data;
your JS decides what to do with it.
By the end of this tutorial you'll have:
- A "Tag this issue" form with an autocomplete
<input>for tags. - Each keystroke calls a
@server_functionthat returns matching tag suggestions. - A
<ul>dropdown rendered by tiny JS — no LiveView re-render. - The user's other in-progress fields (title, description) remain exactly as they typed them.
| You'll learn | Documented in |
|---|---|
| The 3 RPC decorators and when to use which | Server Functions |
djust.call(view_slug, fn_name, kwargs) on the client | Server Functions § djust.call |
| Why no-re-render matters for partial-page widgets | This tutorial |
| Debouncing in plain JS without a framework | This tutorial |
Prerequisites: Quickstart, Forms & Validation, and the search-as-you-type tutorial (recommended — this tutorial argues against that approach for one specific use case).
Why not just use @event_handler?
If you tried this with @event_handler first, you'd see the bug
immediately: while the user is typing in the tag input, every
keystroke triggers a re-render of the entire IssueForm LiveView,
which re-mounts the title <input>, the description <textarea>,
the existing-tags chips, and any other state. Anything the user
hadn't yet sent to the server (e.g. a partially-typed sentence in
the textarea) is preserved by djust's diffing in most cases — but
ancillary state like cursor position and IME composition can flicker.
For a textarea that the user is in the middle of typing in, even imperceptible flicker is bad. And if you have a long form, the overhead of re-rendering it 5–10 times per second while the user types in one field is wasteful.
The fix: tell the framework "I'm calling the server but I don't
want a re-render." That's @server_function.
Step 1 — The model
Standard Django:
# myapp/models.py
from django.db import models
class Tag(models.Model):
name = models.CharField(max_length=64, unique=True)
usage_count = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["-usage_count", "name"]
class Issue(models.Model):
title = models.CharField(max_length=200)
description = models.TextField()
tags = models.ManyToManyField(Tag)
Step 2 — The LiveView with the @server_function
# myapp/views.py
from djust import LiveView, state, action
from djust.decorators import server_function
from .models import Issue, Tag
class NewIssueView(LiveView):
template_name = "new_issue.html"
# Required for djust.call() to address this view from the client
api_name = "issues.new"
title = state("")
description = state("")
selected_tags = state(default_factory=list) # list of {id, name}
@server_function
def search_tags(self, q: str = "", limit: int = 8, **kwargs) -> list[dict]:
q = q.strip()
if not q:
return []
hits = Tag.objects.filter(name__istartswith=q).order_by(
"-usage_count", "name"
)[:limit]
return [{"id": t.id, "name": t.name} for t in hits]
@action
def add_tag(self, id: int = 0, name: str = "", **kwargs):
if not id or not name:
raise ValueError("Tag missing")
if any(t["id"] == id for t in self.selected_tags):
return # already added
self.selected_tags.append({"id": id, "name": name})
@action
def remove_tag(self, id: int = 0, **kwargs):
self.selected_tags = [t for t in self.selected_tags if t["id"] != id]
@action
def submit(self, **kwargs):
if not self.title.strip():
raise ValueError("Title is required")
issue = Issue.objects.create(
title=self.title.strip(),
description=self.description,
)
if self.selected_tags:
issue.tags.set([t["id"] for t in self.selected_tags])
return {"id": issue.id}
Two things to call out:
api_name = "issues.new"— required sodjust.call()from the client can address this LiveView. The slug is opaque to the user but lives in the page-load envelope so JS knows where to dispatch.search_tagsis a@server_function, butadd_tag/remove_tag/submitare@action. That's deliberate: tag suggestions don't change the LiveView's state, but adding / removing a tag does (it changesself.selected_tags, which is reflected in the chip list). Use@server_functiononly when you genuinely don't want a re-render.
Step 3 — The template + tiny JS
<!-- myapp/templates/new_issue.html -->
<form dj-submit="submit">
<label>
Title
<input name="title" value="{{ title }}" required />
</label>
<label>
Description
<textarea name="description">{{ description }}</textarea>
</label>
<fieldset>
<legend>Tags</legend>
<ul class="chips">
{% dj-for tag in selected_tags %}
<li>
{{ tag.name }}
<button type="button" dj-click="remove_tag" dj-payload-id="{{ tag.id }}" aria-label="Remove tag">×</button>
</li>
{% end-dj-for %}
</ul>
<div class="typeahead">
<input
id="tag-input"
type="text"
autocomplete="off"
placeholder="Add a tag…"
aria-label="Search tags"
aria-autocomplete="list"
aria-controls="tag-suggestions"
/>
<ul id="tag-suggestions" role="listbox" hidden></ul>
</div>
</fieldset>
<button type="submit" dj-form-pending="disabled">
<span dj-form-pending="hide">Create issue</span>
<span dj-form-pending="show" hidden>Creating…</span>
</button>
</form>
<script>
(function () {
const input = document.getElementById('tag-input');
const dropdown = document.getElementById('tag-suggestions');
let timer = null;
async function fetchSuggestions(q) {
try {
return await djust.call('issues.new', 'search_tags', { q });
} catch (err) {
console.error('tag search failed', err);
return [];
}
}
function renderSuggestions(hits) {
if (!hits.length) {
dropdown.hidden = true;
return;
}
dropdown.innerHTML = hits.map(h => `
<li role="option" data-id="${h.id}" data-name="${h.name}">${h.name}</li>
`).join('');
dropdown.hidden = false;
}
input.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(async () => {
const hits = await fetchSuggestions(input.value);
renderSuggestions(hits);
}, 150);
});
dropdown.addEventListener('click', (ev) => {
const li = ev.target.closest('li[data-id]');
if (!li) return;
// Tell the LiveView to add the tag (this DOES re-render)
djust.dispatch('add_tag', {
id: parseInt(li.dataset.id, 10),
name: li.dataset.name,
});
input.value = '';
dropdown.hidden = true;
});
})();
</script>
What's doing what:
| Element | Behavior |
|---|---|
djust.call('issues.new', 'search_tags', { q }) | Calls the @server_function over HTTP, returns the JSON result. No re-render. |
setTimeout(..., 150) | 150 ms client-side debounce — same tradeoff as dj-debounce but with vanilla JS because we're orchestrating manually. |
djust.dispatch('add_tag', {...}) | Fires the add_tag event on the LiveView — this does trigger a re-render, which is what we want (the chip list needs to update). |
dj-form-pending on submit button | Standard form-pending UX during the final submit. |
The user can type in the description textarea, focus the tag input, type to search, click a suggestion, and watch the chip appear at the top — all without losing the cursor in the textarea or seeing any flicker on unrelated elements.
When to use which RPC primitive
This is the table from the Server Functions guide, restated as a decision rule:
| You want… | Use |
|---|---|
| Click / submit / input → state change → DOM update | @event_handler |
| Same as above, but also callable from mobile / S2S / AI agents | @event_handler(expose_api=True) |
| Server data, no re-render, no external API | @server_function |
The third row is the rarest of the three — most interactions do
want a re-render. But for autocomplete, "is this email already
registered" inline checks, async price calculators, and any other
"side data fetch" you don't want polluting the diff cycle,
@server_function is the cleanest tool.
Where to go next
- Keyboard navigation: add ↑/↓/Enter handling on the suggestion dropdown so users don't have to mouse over each option. The pattern is identical to the Cmd+K search modal in docs.djust.org if you want a reference.
- Cancellation: if the user types "ab" then "abc" before the "ab" call returns, you'll briefly render stale "ab" suggestions on top of the "abc" results. Use a per-call sequence number and drop responses whose sequence is older than the latest one fired.
- Inline validation: the same pattern works for "is this email
already registered" —
@server_functionreturns{available: bool, reason?: str}, the JS toggles a small icon next to the field. No re-render, so the password field below isn't re-mounted. - Result cache: for hot autocomplete (e.g. country picker), wrap
the
@server_functionbody in@functools.lru_cachekeyed onq. djust does not cache@server_functionresponses for you.
The decision between @event_handler and @server_function is one
of the rare actually-architectural choices djust pushes onto you.
Most of the time you want the diff cycle. When you don't — when the
data you're fetching is adjacent to the page state, not part of
it — @server_function exists and is the cleanest tool for the job.