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 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";
}
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"
}
}
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.
// 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 |