Server-Side State Management

Manage complex UI state (pagination, filters, tabs) without writing client-side JavaScript.

The State Problem

In traditional SPAs, state management is complex. You have Redux, Context, or Signals to synchronize client state with server data.

In Swap.Htmx, the server is the single source of truth. State is serialized into the DOM (as hidden inputs) and passed back to the server with every request.


Quick Start in 3 Steps

1. Define Your State Class

Inherit from SwapState. Properties in this class will be automatically serialized.

using Swap.Htmx.State;

public class TodoFilterState : SwapState
{
    public string Group { get; set; } = "all";
    public bool ShowCompleted { get; set; } = false;
    public string SearchTerm { get; set; } = "";
}

2. Render the State Container

Use the <swap-state> tag helper in your view. This renders a hidden <div> containing all your state properties as <input type="hidden"> fields.

@model TodoViewModel

<!-- Renders <div id="todo-filter-state">...</div> -->
<swap-state state="Model.State" />

<!-- Tell HTMX to include this state in requests -->
<button hx-get="@Url.Action("Filter")"
        hx-target="#todo-list"
        hx-include="#todo-filter-state">
    Apply Filters
</button>

3. Bind in Controller

Use the [FromSwapState] attribute to automatically bind strictly typed state from the request.

[HttpGet]
public IActionResult Filter([FromSwapState] TodoFilterState state)
{
    // state.Group is populated from the hidden inputs
    var items = _service.GetTodos(state.Group, state.ShowCompleted);
    
    return SwapResponse()
        .WithView("_TodoList", items)
        .WithState(state) // Automatically updates the hidden inputs on the client!
        .Build();
}

How It Works

  1. Serialization: The <swap-state> tag converts C# properties into HTML inputs.
  2. Transmission: hx-include grabs these inputs and sends them as Form Data (or Query Params) to the server.
  3. Binding: [FromSwapState] rebuilds the C# object.
  4. Updates: WithState(state) sends a new hidden div (hx-swap-oob) to update the client's state for the next interaction.

Best Practices

  • Keep it Small: Use it for UI state (tabs, pagination, simple filters). Don't store your entire database here.
  • Secure logic: Never trust the state coming from the client (it's just HTML inputs). Always validate it.
  • Default Values: Initialize properties in your class so they have sensible defaults if missing.
public class PaginationState : SwapState
{
    public int Page { get; set; } = 1;      // Default: Page 1
    public int PageSize { get; set; } = 20; // Default: 20 items
}

🔒 Security & Tamper-Proofing

By default, state is stored as plain text. Users can inspect and modify hidden inputs. Never trust client state for sensitive data.

For sensitive values (like IDs, prices, or roles), you can enable Tamper-Proofing.

Enable Protection

Inherit from SwapState and set Protected = true in the constructor.

public class PaymentState : SwapState
{
    public PaymentState()
    {
        Protected = true; // Enables encryption/signing for this state object
    }
    
    public decimal Amount { get; set; }
    public int OrderId { get; set; }
    
    // Opt-out specific properties if needed (they will be plain text)
    [SwapUnprotected]
    public string UserNote { get; set; }
}

How It Works

  • Protected properties are encrypted/signed using ASP.NET Core IDataProtection.
  • If a user tampers with the value, the decryption fails.
  • The server will either throw or ignore the tampered value (depending on config).
  • [SwapProtected] attribute can be used for fine-grained control if you don't want to protect the whole class.

Secure URLs

When using Protected = true, query strings must also be signed. Use the helper:

<a href="@Html.SwapStateQueryString(state, "Index")">Next Page</a>