important-details.md 12 KB

Spry Framework - Important Details

Component Basics

Template DOM is NOT Persisted

  • The template is fresh on every request
  • Any state set via setters (like title, completed) is NOT available in handle_action()
  • Always use prepare() to fetch data from store and set up the template

The prepare() Method

  • Called automatically before serialization
  • Use this to fetch data from stores and populate the template
  • Centralizes 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";
    }
    

The handle_action() Method

  • Called when HTMX requests trigger an action
  • Modify state in stores, then let prepare() handle template updates
  • For 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"
    }
    }
    

HTMX Integration

Declarative Attributes

spry-action - Declare HTMX Actions

  • spry-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 attribute
  • Only works within the SAME component
  • Automatically generates proper hx-target with unique IDs

hx-target - Global Targeting (cross-component)

  • Use hx-target="#id" to target elements anywhere on the page
  • Requires the target element to have an id attribute (not just sid)

hx-swap - Swap Strategies

  • hx-swap="outerHTML" - Replace entire target element (prevents nesting)
  • hx-swap="delete" - Remove target element (no response content needed)

Out-of-Band Swaps with 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:

  • Automatically adds hx-swap-oob="true" when the element appears in a response
  • HTMX finds the existing element on the page and swaps it in place
  • Use add_globals_from(component) to append spry-global elements from another component
  • The component's prepare() method is called automatically to populate data

Passing Data with hx-vals

Since 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.

IoC Composition

Registration Patterns

// State as singleton
application.add_singleton<AppState>();

// Factory as scoped
application.add_scoped<ComponentFactory>();

// Components as transient
application.add_transient<MyComponent>();

Dependency Injection

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>();
}

Creating Components

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);

Common Patterns

List with Items Pattern

// 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);

Cross-Component Action Pattern

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

HTML Escaping

<pre> and <code> tags do NOT escape their contents. Manually escape:

// Wrong
<pre><button spry-action=":Toggle">Click</button></pre>

// Correct
<pre>&lt;button spry-action=":Toggle"&gt;Click&lt;/button&gt;</pre>

SpryModule

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.

Declarative Child Components with <spry-component>

When to Use <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)

Declaring Child Components

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

Accessing Child Components with 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 - Combining Component and Endpoint

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 { /* ... */ }
}

SpryConfigurator - Component and Page Registration

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"));

Registration Methods

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