title, completed) is NOT available in handle_action()prepare() to fetch data from store and set up the templateprepare() MethodCentralizes data fetching logic in one place
public override async void prepare() throws Error {
var item = store.get_by_id(_item_id);
if (item == null) return;
this["title"].text_content = item.title;
this["button"].text_content = item.completed ? "Undo" : "Done";
}
prepare_once() Methodprepare() callRuns before prepare() in the same request lifecycle
public override async void prepare_once() throws Error {
// One-time setup, e.g., loading initial data
initial_data = yield fetch_initial_data();
}
handle_action() Methodprepare() handle template updatesFor delete with hx-swap="delete", just return (no content needed)
public async override void handle_action(string action) throws Error {
var id = get_id_from_query_params();
_item_id = id; // Set for prepare()
switch (action) {
case "Toggle":
store.toggle(id);
break; // prepare() will be called automatically
case "Delete":
store.remove(id);
return; // HTMX removes element via hx-swap="delete"
}
}
The continuation feature allows a Component to send real-time updates to the client via Server-Sent Events (SSE). This is useful for:
continuation() MethodOverride continuation(ContinuationContext context) to send SSE events:
public async override void continuation(ContinuationContext continuation_context) throws Error {
for (int i = 0; i <= 100; i += 10) {
percent = i;
status = @"Processing... $(i)%";
// Send fragment updates to the client
yield continuation_context.send_fragment("progress", "progress-bar");
yield continuation_context.send_fragment("status", "status");
Timeout.add(500, () => {
continuation.callback();
return false;
});
yield;
}
status = "Complete!";
yield continuation_context.send_fragment("status", "status");
}
continuation_canceled() MethodCalled when the client disconnects from the SSE stream:
public async override void continuation_canceled() throws Error {
// Clean up resources when client disconnects
cleanup_task();
}
| Method | Description |
|---|---|
send_fragment(event_type, sid) |
Send a fragment (by sid) as an SSE event with the given event type |
send_json(event_type, node) |
Send JSON data as an SSE event |
send_string(event_type, data) |
Send raw string data as an SSE event |
send_full_update(event_type) |
Send the entire component document |
send_event(event) |
Send a custom SseEvent |
spry-continuation AttributeAdd spry-continuation to an element to enable SSE:
public override string markup { get {
return """
<div spry-continuation>
<div class="progress-bar" sid="progress-bar" sse-swap="progress">
0%
</div>
<div class="status" sid="status" sse-swap="status">
Initializing...
</div>
</div>
""";
}}
The spry-continuation attribute is shorthand for:
hx-ext="sse" - Enable HTMX SSE extensionsse-connect="(auto-generated-endpoint)" - Connect to the SSE endpointsse-close="_spry-close" - Close event namesse-swap AttributeUse sse-swap="eventname" on child elements to specify which SSE event type should swap the content:
<div sid="progress-bar" sse-swap="progress">...</div>
<div sid="status" sse-swap="status">...</div>
When continuation_context.send_fragment("progress", "progress-bar") is called, the fragment with sid="progress-bar" is sent as an SSE event with type "progress", and HTMX swaps it into the element listening for that event type.
Include the HTMX SSE extension in your markup:
<script spry-res="htmx.js"></script>
<script spry-res="htmx-sse.js"></script>
spry-action - Declare HTMX Actionsspry-action=":ActionName" - Action on same component (e.g., :Toggle)spry-action="ComponentName:ActionName" - Action on different component (cross-component)spry-target - Scoped Targeting (within same component)spry-target="sid" - Targets element by its sid attributehx-target with unique IDshx-target - Global Targeting (cross-component)hx-target="#id" to target elements anywhere on the pageid attribute (not just sid)hx-swap - Swap Strategieshx-swap="outerHTML" - Replace entire target element (prevents nesting)hx-swap="delete" - Remove target element (no response content needed)spry-global and add_globals_from()Update multiple elements on the page with a single response using spry-global:
// 1. Add spry-global to elements that may need OOB updates
// 2. Use prepare() to fetch data from store - no manual property setting needed!
class HeaderComponent : Component {
private TodoStore todo_store = inject<TodoStore>();
public override string markup { get {
return """
<div class="card" id="header" spry-global="header">
<h1>Todo App</h1>
<div sid="total"></div>
</div>
""";
}}
// prepare() fetches data from store automatically
public override async void prepare() throws Error {
this["total"].text_content = todo_store.count().to_string();
}
}
// 3. Inject the component and pass it to add_globals_from()
class ItemComponent : Component {
private HeaderComponent header = inject<HeaderComponent>();
public override string markup { get {
return """
<div sid="item">...</div>
""";
}}
public async override void handle_action(string action) throws Error {
store.toggle(id);
// Add header globals for OOB swap (header's prepare() fetches stats)
add_globals_from(header);
}
}
The spry-global="key" attribute:
hx-swap-oob="true" when the element appears in a responseadd_globals_from(component) to append spry-global elements from another componentprepare() method is called automatically to populate datahx-valsSince template DOM is not persisted, use hx-vals to pass data:
// In prepare() - set on parent element, inherited by children
this["item"].set_attribute("hx-vals", @"{\"id\":$_item_id}");
// In handle_action()
var id_str = http_context.request.query_params.get_any_or_default("id");
var id = int.parse(id_str);
Note: hx-vals is inherited by child elements, so set it on the parent div rather than individual buttons.
*-expr)Use *-expr attributes to dynamically set any attribute based on component properties:
class ProgressComponent : Component {
public int percent { get; set; }
public override string markup { get {
return """
<div class="progress-bar"
content-expr='format("%i%%", this.percent)'
style-width-expr='format("%i%%", this.percent)'>
0%
</div>
""";
}}
}
Expression attribute patterns:
content-expr="expression" - Set text/HTML contentclass-expr="expression" - Set CSS classesstyle-expr="expression" - Set inline styles (e.g., style-width-expr)any-attr-expr="expression" - Set any attribute dynamicallyFor class-* attributes, boolean expressions add/remove the class:
<div class-completed-expr="this.is_completed">Item</div>
If this.is_completed is true, the completed class is added.
spry-ifUse spry-if, spry-else-if, and spry-else for conditional rendering:
<div spry-if="this.is_admin">Admin Panel</div>
<div spry-else-if="this.is_moderator">Moderator Panel</div>
<div spry-else>User Panel</div>
spry-per-*Use spry-per-{varname}="expression" to iterate over collections:
<div spry-per-task="this.tasks">
<span content-expr="task.name"></span>
</div>
The variable name after spry-per- (e.g., task) becomes available in nested expressions.
spry-resUse spry-res to reference Spry's built-in static resources:
<script spry-res="htmx.js"></script>
<script spry-res="htmx-sse.js"></script>
This resolves to /_spry/res/{resource-name} automatically.
// State as singleton
application.add_singleton<AppState>();
// Factory as scoped
application.add_scoped<ComponentFactory>();
// Components as transient
application.add_transient<MyComponent>();
Use inject<T>() in field initializers:
class MyComponent : Component {
private TodoStore store = inject<TodoStore>();
private ComponentFactory factory = inject<ComponentFactory>();
private HttpContext http_context = inject<HttpContext>();
}
Use ComponentFactory.create<T>() (not inject<T>() which only works in field initializers):
var child = factory.create<ChildComponent>();
child.item_id = item.id;
items.add(child);
// Parent component
class ListComponent : Component {
public void set_items(Enumerable<Renderable> items) {
set_outlet_children("items", items);
}
public override string markup { get {
return """
<div id="my-list" sid="my-list">
<spry-outlet sid="items"/>
</div>
""";
}}
}
// Item component with prepare()
class ItemComponent : Component {
private TodoStore store = inject<TodoStore>();
private HttpContext http_context = inject<HttpContext>();
private HeaderComponent header = inject<HeaderComponent>();
private int _item_id;
public int item_id { set { _item_id = value; } }
public override string markup { get {
return """
<div class="item" sid="item">
<span sid="title"></span>
<button sid="toggle-btn" spry-action=":Toggle" spry-target="item" hx-swap="outerHTML"></button>
</div>
""";
}}
public override void prepare() throws Error {
var item = store.get_by_id(_item_id);
this["title"].text_content = item.title;
// hx-vals on parent is inherited by children
this["item"].set_attribute("hx-vals", @"{\"id\":$_item_id}");
}
public async override void handle_action(string action) throws Error {
var id = int.parse(http_context.request.query_params.get_any_or_default("id"));
_item_id = id;
if (action == "Toggle") {
store.toggle(id);
// Add header globals for OOB swap (header's prepare() fetches stats)
add_globals_from(header);
}
}
}
// Creating the list
var items = new Series<Renderable>();
store.all().iterate((item) => {
var component = factory.create<ItemComponent>();
component.item_id = item.id; // Only set ID, prepare() handles rest
items.add(component);
});
list.set_items(items);
// Form in one component triggers action on another
<form spry-action="ListComponent:Add" hx-target="#my-list" hx-swap="outerHTML">
<input name="title"/>
<button type="submit">Add</button>
</form>
// ListComponent handles the Add action
class ListComponent : Component {
public async override void handle_action(string action) throws Error {
if (action == "Add") {
var title = http_context.request.query_params.get_any_or_default("title");
store.add(title);
}
// Rebuild list...
}
}
<pre> and <code> tags do NOT escape their contents. Manually escape:
// Wrong
<pre><button spry-action=":Toggle">Click</button></pre>
// Correct
<pre><button spry-action=":Toggle">Click</button></pre>
Automatically registers ComponentEndpoint at /_spry/{component-id}/{action}:
application.add_module<SpryModule>();
This enables the declarative spry-action attributes to work without manual endpoint registration.
<spry-component><spry-component> vs <spry-outlet><spry-component name="ComponentName" sid="..."/> - For single, known child components<spry-outlet sid="..."/> - For dynamic lists (multiple items from data)Use <spry-component> in markup for declarative composition:
class TodoPage : PageComponent {
public override string markup { get {
return """
<!DOCTYPE html>
<html>
<body>
<spry-component name="HeaderComponent" sid="header"/>
<spry-component name="TodoListComponent" sid="todo-list"/>
<spry-component name="AddFormComponent" sid="add-form"/>
<spry-component name="FooterComponent" sid="footer"/>
</body>
</html>
""";
}}
}
get_component_child<T>()Use get_component_child<T>(sid) in prepare() to access and configure child components:
class TodoPage : PageComponent {
private TodoStore todo_store = inject<TodoStore>();
private ComponentFactory factory = inject<ComponentFactory>();
public override async void prepare() throws Error {
// Get child component and configure it
var todo_list = get_component_child<TodoListComponent>("todo-list");
// Populate the list (which still uses spry-outlet for dynamic items)
var items = new Series<Renderable>();
todo_store.all().iterate((item) => {
var component = factory.create<TodoItemComponent>();
component.item_id = item.id;
items.add(component);
});
todo_list.set_items(items);
}
}
PageComponent is a base class that acts as both a Component AND an Endpoint. This eliminates the need for separate endpoint classes:
// Before: Separate endpoint class
class HomePageEndpoint : Object, Endpoint {
private ComponentFactory factory = inject<ComponentFactory>();
public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
var page = factory.create<PageLayoutComponent>();
return yield page.to_result();
}
}
// After: PageComponent handles both roles
class TodoPage : PageComponent {
public override string markup { get { return "..."; } }
public override async void prepare() throws Error { /* ... */ }
}
Use SpryConfigurator to register components and pages in a structured way:
// Get the configurator
var spry_cfg = application.configure_with<SpryConfigurator>();
// Register child components (transient lifecycle)
spry_cfg.add_component<HeaderComponent>();
spry_cfg.add_component<TodoListComponent>();
spry_cfg.add_component<TodoItemComponent>();
spry_cfg.add_component<AddFormComponent>();
spry_cfg.add_component<FooterComponent>();
// Register pages (scoped lifecycle, acts as Endpoint)
spry_cfg.add_page<TodoPage>(new EndpointRoute("/"));
// Other endpoints still use add_endpoint
application.add_endpoint<TodoJsonEndpoint>(new EndpointRoute("/api/todos"));
| Method | Lifecycle | Use Case |
|---|---|---|
add_component<T>() |
Transient | Child components created via factory |
add_page<T>(route) |
Scoped | Page components that act as endpoints |
add_template<T>(prefix) |
Transient | Page templates for layout wrapping |