important-details.md 8.1 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.