Build CRUD pages
Imagine a CRUD admin table view. Each row is one record, and each row has buttons for editing or deleting the row. Each row also has its own loading, errors, and success message. Meanwhile, the controls above and below the table may need to move together.
You will build that shape in three layers:
- one
TaskRowinstance per task; - one
onEventfor each row's result; - one
TaskListthat rendersTaskRowand manages the list controls.
Continue from Update page from Python. Keep citry_setup.py and app.py unchanged.
Build the page
Replace components.py with this version:
from dataclasses import dataclass
from citry import Component
from citry.ext.events import EventError, actions
from citry_setup import citry_app
@dataclass
class Task:
id: int
title: str
completed: bool = False
# This represents the "database" of tasks.
# In a real app, this would be stored in a database.
TASKS = [
Task(id=1, title="Review the draft", completed=True),
Task(id=2, title="Send the invitation"),
Task(id=3, title="Publish the notes"),
]
def load_tasks(*, hide_completed: bool = False) -> list[Task]:
if hide_completed:
return [task for task in TASKS if not task.completed]
return TASKS
class RenameTaskIn:
title: str
class TaskRow(Component):
citry = citry_app
class Kwargs:
task_id: int
title: str
class Slots:
pass
# Remember the task ID in State so we don't have
# to send it with each event.
class State:
task_id: int
class Events:
# Update the task title in TASKS.
# Return a message to display in the UI.
def save(self, data: RenameTaskIn, state: "TaskRow.State"):
title = data.title.strip()
if len(title) < 3:
raise EventError(
"Give the task a longer title.",
fields={"title": "Use at least three characters."},
)
# Perform a "database" update.
for task in TASKS:
if task.id == state.task_id:
task.title = title
break
return actions.Dispatch(
"TaskRow:saved",
{"taskId": state.task_id, "title": title},
)
def template_data(self, kwargs: Kwargs, slots: Slots):
return {
"task_id": kwargs.task_id,
"title": kwargs.title,
}
template = """
<li class="task-row">
<form @c-submit.prevent="save">
<label>
Task {{ task_id }}
<input
name="title"
c-value="title"
required
/>
</label>
<button
type="submit"
:disabled="$loading('save')"
>
Save
</button>
<p
role="alert"
x-show="$error('save')"
x-text="$error('save')?.fieldErrors?.title || ''"
></p>
<output x-text="saveStatus"></output>
</form>
</li>
"""
js = """
// Display a message when this row's task title
// is successfully saved.
$component(({ onEvent, scope }) => {
scope.saveStatus = '';
onEvent('TaskRow:saved', (detail) => {
scope.saveStatus =
`Saved task ${detail.taskId}: ${detail.title}`;
});
});
"""
class TaskRows(Component):
citry = citry_app
class Kwargs:
tasks: list[Task]
class Slots:
pass
def template_data(self, kwargs, slots):
return {"tasks": kwargs.tasks}
template = """
<c-for each="task in tasks">
<c-TaskRow
#c-key="task.id"
c-task_id="task.id"
c-title="task.title"
/>
</c-for>
"""
class FilterTasksIn:
hide_completed: bool
class TaskFilterToggle(Component):
citry = citry_app
class Kwargs:
pass
class Slots:
pass
template = """
<button
type="button"
:disabled="clientProps.loading"
x-text="
clientProps.hideCompleted
? 'Show all tasks'
: 'Hide completed tasks'
"
></button>
"""
js = """
$component({
props: {
hideCompleted: { type: Boolean, required: true },
loading: { type: Boolean, required: true },
},
init: ({ props, scope }) => {
scope.clientProps = props;
},
});
"""
class TaskList(Component):
citry = citry_app
class Kwargs:
tasks: list[Task]
class Slots:
pass
class Events:
def filter_tasks(self, data: FilterTasksIn):
visible_tasks = load_tasks(
hide_completed=data.hide_completed,
)
return [
actions.Dispatch(
"TaskList:filter-changed",
{"hideCompleted": data.hide_completed},
),
actions.Render(
TaskRows(tasks=visible_tasks),
target="#task-rows",
swap="inner",
),
]
def template_data(self, kwargs, slots):
return {
"tasks": kwargs.tasks,
}
template = """
<section>
<c-TaskFilterToggle
$c-props="{
hideCompleted,
loading: $loading('filter_tasks'),
}"
@c-click="filter_tasks({
hide_completed: !hideCompleted,
})"
/>
<ul id="task-rows">
<c-TaskRows c-tasks="tasks" />
</ul>
<c-TaskFilterToggle
$c-props="{
hideCompleted,
loading: $loading('filter_tasks'),
}"
@c-click="filter_tasks({
hide_completed: !hideCompleted,
})"
/>
</section>
"""
js = """
$component(({ onEvent, scope }) => {
scope.hideCompleted = false;
onEvent(
'TaskList:filter-changed',
(detail) => {
scope.hideCompleted = detail.hideCompleted;
},
);
});
"""
class TutorialPage(Component):
citry = citry_app
class Kwargs:
pass
class Slots:
pass
def template_data(self, kwargs, slots):
return {"tasks": load_tasks()}
template = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Task list</title>
<c-css />
</head>
<body>
<main>
<h1>Task list</h1>
<c-TaskList c-tasks="tasks" />
</main>
<c-js />
</body>
</html>
"""
Open http://127.0.0.1:8000/. Each row can save independently. Try a two-character title in one row to keep its validation error visible, then save another row. The first error stays in place.
TaskList renders the same TaskFilterToggle component above and below the rows. Hide completed tasks with either control. The server sends back the two unfinished tasks, and both controls change to "Show all tasks."
Preserve browser state
The list renders the same component class several times:
<c-for each="task in tasks">
<c-TaskRow
#c-key="task.id"
c-task_id="task.id"
c-title="task.title"
/>
</c-for>
Each <c-TaskRow> is a separate component instance with its own State, call queue, loading counters, and handler errors.
#c-key="task.id" tells Citry which row is which, so Citry can safely morph them. Morphing means that when you re-fetch the list with changed order or items, any browser state of the old list keeps working (eg a text field keeps the end user's input). Without the key, rows are matched by position.
Note
Use a stable application identifier for the key. A database primary key, slug, or other domain ID is suitable. DO NOT use a Citry component ID, it changes with each render.
Pass inputs to event handlers
The save handler needs the new title and the ID of the task to update. There are three good ways to give it that ID.
Keep the ID in State
This lesson keeps the ID in State:
class RenameTaskIn:
title: str
class TaskRow(Component):
class State:
task_id: int
In this component, Citry starts state.task_id from the matching Kwargs.task_id, which the list passes with c-task_id. When the form is submitted, RenameTaskIn carries the edited title, while State remembers which task this row belongs to. The form does not need a hidden field for the ID.
Submit the ID as a form field
The second option is to send both values with the form:
class RenameTaskIn:
task_id: int
title: str
<input
type="hidden"
name="task_id"
c-value="task_id"
/>
The handler would then use data.task_id, and TaskRow would not need a State class. This keeps everything save needs together in data, but every form must now carry its own task_id field.
Pass the ID from Alpine
The third option is to keep the ID in Alpine and add it when the form calls save:
<form
c-x-data="{ taskId: task_id }"
@c-submit.prevent="save({ task_id: taskId })"
>
<!-- The title input and submit button stay the same. -->
</form>
@c-submit combines the form's named controls with the object passed to save, so RenameTaskIn receives both title and task_id. This version also needs no State class or hidden input.
Error and loading state
Each row is an isolated component environment:
- Own event handlers
- Own State
- Own Alpine data context
Inside the row, call $loading() and $error() to get loading/error state scoped to this row:
<button type="submit" :disabled="$loading('save')">
Save
</button>
<p
role="alert"
x-show="$error('save')"
x-text="$error('save')?.fieldErrors?.title || ''"
></p>
The handler name 'save' picks errors specifically coming from the event handler named save. A success clears the error.
When one component contains several handlers, $error() returns its newest retained error for a component-wide banner. $error('save') remains the better choice beside one form.
$error() and $loading() are also accessible inside the component callback $component as error() and loading(). Note, these functions return Alpine reactive objects, and the accessing logic must be wrapped in effect():
$component(({ error, loading, effect }) => {
// Same data as the ones in `x-data` and `:disabled`
scope.text = '';
scope.disabled = false;
// Call the functions inside effect() so the changes propagate
effect(() => {
scope.text = error('save')?.fieldErrors?.title || '';
scope.disabled = loading('save');
});
});
Event actions are isolated
Every row uses the same event handler save, and so returns the same event name:
return actions.Dispatch(
"TaskRow:saved",
{"taskId": state.task_id, "title": title},
)
Despite this, the dispatched event TaskRow:saved DOES NOT leak across the rows.
When you use onEvent, Citry smartly passes that Dispatch action to the component instance whose handler returned it. The row listens with its instance-scoped onEvent helper:
$component(({ onEvent, scope }) => {
scope.saveStatus = '';
onEvent('TaskRow:saved', (detail) => {
scope.saveStatus =
`Saved task ${detail.taskId}: ${detail.title}`;
});
});
Bypass Dispatch event isolation
The recommended pattern is to use onEvent and keep the events coming from Dispatch isolated.
But if you need, you can use Alpine's @event listeners or browser's addEventListener() to listen for the events.
The browser events triggered by Dispatch are regular browser events. For example, Dispatch("taskrow:saved", ...) can be heard by @taskrow:saved="...".
Regular event bubbling rules apply - the listener hears the event on the dispatching root or an ancestor, but not on a descendant or a sibling.
Filter feature end-to-end
One task in the example is already complete. Filtering therefore needs a server request: Python chooses which tasks still match, then rerenders the list with two rows instead of three.
TaskFilterToggle owns the button markup. It reads the reactive values its parent passes through clientProps:
<button
type="button"
:disabled="clientProps.loading"
x-text="
clientProps.hideCompleted
? 'Show all tasks'
: 'Hide completed tasks'
"
></button>
TaskList renders that component twice. Each copy receives the same list filter and calls the same list-owned handler:
<c-TaskFilterToggle
$c-props="{
hideCompleted,
loading: $loading('filter_tasks'),
}"
@c-click="filter_tasks({
hide_completed: !hideCompleted,
})"
/>
<!-- Task rows appear between the two controls. -->
<c-TaskFilterToggle
$c-props="{
hideCompleted,
loading: $loading('filter_tasks'),
}"
@c-click="filter_tasks({
hide_completed: !hideCompleted,
})"
/>
The $c-props expressions run in TaskList, so both controls read its hideCompleted value and loading state.
When TaskFilterToggle is clicked, this triggers the @c-click listener, which calls the server-side filter_tasks. The @c-click belongs to TaskList. The browser listens for the click on the real button rendered by TaskFilterToggle.
The filter_tasks event handler uses the requested filter to select tasks on the server. It then returns two actions in order:
# TaskList.Events.filter_tasks
def filter_tasks(self, data: FilterTasksIn)
visible_tasks = load_tasks(
hide_completed=data.hide_completed,
)
return [
actions.Dispatch(
"TaskList:filter-changed",
{"hideCompleted": data.hide_completed},
),
actions.Render(
TaskRows(tasks=visible_tasks),
target="#task-rows",
swap="inner",
),
]
The Render action replaces the contents of #task-rows with a TaskRows component containing only the matching rows. It leaves TaskList and both filter controls in place. The Dispatch action updates that existing list scope and tells both controls whether the filter is now active.
TaskList registers an instance-scoped listener in its $component callback:
$component(({ onEvent, scope }) => {
scope.hideCompleted = false;
onEvent(
'TaskList:filter-changed',
(detail) => {
scope.hideCompleted = detail.hideCompleted;
},
);
});
The callback updates TaskList's scope.hideCompleted - this exposes hideCompleted as an Alpine template variable (for this instance only). This is the same hideCompleted that's passed down to TaskFilterToggle's $c-props.
The reactive props then update both TaskFilterToggle instances. If the next click comes from the other control, it now sends false and asks the server for all three tasks again. Citry removes the scoped subscription when the list instance leaves the page.
This page has one TaskList and therefore one #task-rows update target. In a real-life application, you might want to update only individual rows instead of re-rendering entire list.
Dispatch vs Data actions
The filter controls send the events to the server using @c-click. @c-click only declares what server event handler to send the event to, but it can't handle the server response.
That's why the server responds with a Dispatch action - this works around the @c-click's limitation:
@c-clicktriggers a server event.- Server-side handler receives and processes the event.
- The server returns a
Dispatchaction to trigger a browser event'TaskList:filter-changed'. - In the browser, separate
onEvent()callback is registered inTaskListto listen for this event. - Inside the
onEvent(), we can access the data sent with this event.
There is a simpler way: When you want to trigger a server event AND get data back from the server, you can instead use sendEvent() together with actions.Data.
sendEvent() returns a Promise that resolves to the data returned by actions.Data:
const result = await sendEvent(
'filter_tasks',
{ hide_completed: !hideCompleted },
);
// result === { hideCompleted: true }
And the Python action would have look like:
actions.Data(
{"hideCompleted": data.hide_completed},
)
In the browser, the code would look like this:
Replace onEvent in TaskList's JavaScript with sendEvent:
$component(({ sendEvent, scope }) => {
scope.hideCompleted = false;
scope.onFilterTasks = () => {
const result = await sendEvent(
'filter_tasks',
{ hide_completed: !scope.hideCompleted },
);
scope.hideCompleted = result.hideCompleted;
};
});
And in the template, replace @c-click with regular @click on TaskFilterToggle:
<c-TaskFilterToggle
$c-props="{
hideCompleted,
loading: $loading('filter_tasks'),
}"
@click="onFilterTasks"
/>
Bottom-line: Use Dispatch when @c-* starts the call and browser listeners must react. A handler may return both when it supports both call styles.
Keep building
Congratulations, you've reached the end of the tutorial.
You're now ready to get building! :)
You've now have the core patterns for editable tables, kanban columns, search results, and other repeated interactive components:
From here:
- Use Examples when you want working code for a specific task.
- Read Docs when you want a concept or guided workflow.
- Read Reference when you need the exact API for a class, method, or return action.
- Install Citry UI, a library of reusable UI components.
- Install Citry linter for your IDE, to get syntax highlight, diagnostics, and more.