A no-dependency htmx CRUD app — add, search, edit, and delete contacts — built on
plain Chirp return types and in-memory state. Reach for it after
your first fragment app when you want a
realistic mutation loop: a search box that swaps a table, a form that re-renders
with 422 validation errors, an add that updates the table and its count badge in
one response, and a delete that re-renders the table and fires anHX-Trigger
event to drive a toast.
What It Teaches
- [[docs/about/core-concepts/return-values|
Page]] for full-page vs. fragment negotiation - [[docs/build-apps/html-fragments/fragments|
Fragment]] for search and delete updates - [[docs/build-apps/forms-data/forms-validation|
ValidationError]] for 422 form re-renders - [[docs/quality/contracts-debugging/oob-registry|
OOB]] for updating the table and count in one response - an
HX-Triggerevent that drives a toast after delete - frozen dataclasses plus a lock for thread-safe in-memory state
Run It
PYTHONPATH=src python examples/standalone/contacts/app.pypytest examples/standalone/contacts/Then openhttp://127.0.0.1:8000/.
One Response, Two Targets
Adding a contact updates two regions in a single response: the table re-renders,
and the heading's count badge swaps out of band. The handler returns oneOOB
wrapping twoFragments.
@app.route("/contacts", methods=["POST"], name="contacts.add")
async def add_contact(request: Request):
form = await request.form()
result = validate(form, _CONTACT_RULES)
if not result:
return ValidationError(
"contacts.html", "contact_form",
retarget="#form-section",
errors=result.errors,
form={"name": form.get("name", ""), "email": form.get("email", "")},
)
_add_contact(form.get("name", ""), form.get("email", ""))
contacts = _get_contacts()
return OOB(
Fragment("contacts.html", "contact_table", contacts=contacts),
Fragment("contacts.html", "contact_count", target="contact-count", count=len(contacts)),
)
Source: examples/standalone/contacts/app.py.