Developer Docs - Mobile Docs - Data Lists and Paging
Most Helix swaps place a XAML fragment into the page. The two items strategies do something different: the endpoint returns a JSON array, and the app feeds it to a native list as that list's items. The list keeps its own item template, scrolls with full virtualization, and asks for the next page on its own as the person nears the end.
This is how you build a long roster, a search screen, or any feed that is too big to render all at once, without writing a custom block.
When to use it
append and a Load More button, described in Swaps and Animations, work well for short lists. Every row becomes a real control inside a layout, nothing is recycled, and each batch nests one layout deeper than the last. That is fine for a few dozen rows and gets heavier from there.
Reach for an items swap when:
Stay with XAML fragments when the rows differ in structure from one to the next, or when the list is short enough that recycling buys you nothing.
The items strategies
Both are set with Hx.Swap, like any other strategy, and both are mobile only. HTMX has no data swap to alias, so a shared endpoint should only send them to the app.
Target types
Anything else is an error. A VerticalStackLayout, a layout using BindableLayout, and Rock:ItemsView are not list targets, even though they can show a list. The error names the type it found: The "items-append" swap needs a CollectionView, CarouselView or ItemsCollection target, not a VerticalStackLayout. An items swap also cannot target coversheet.
Self-loading lists
A list that fires its own request, with no Hx.Target on it or any ancestor, targets itself. That is the usual setup: the list loads on load and fills itself.
Hx.Target inherits, though. If a container above the list declares a target for its buttons, the list inherits that too, and its JSON heads for the wrong element and fails the target check. Writing Hx.Target="this" on the list makes it immune to whatever its ancestors declare. See Inheritance.
Response format
Your endpoint's Code Template emits a JSON array of objects, one per row. Each object's keys are what the item template binds to.
The app decides to read JSON from the swap strategy, not from the endpoint's Content Type. Setting the endpoint's Content Type to application/json is still good practice, because a web caller of the same endpoint does read it.
How values convert
Items convert exactly the way {Rock:FromJson} converts JSON, so anything you already know about binding to FromJson data applies here.
Item templates
The item template lives on the list in your page XAML, not in the response. Each item's binding context is one object from the array, so you bind to its keys.
Use the indexer form, {Binding [Key]}. It reads the key straight from the item, it is the form tested with items swaps, and it is the only form that works for a key containing a space or a dash.
The property form, {Binding Key}, resolves through the same model and also works for simple keys. Pick one and use it consistently in a template.
Keys are case-sensitive. {Binding [name]} does not find "Name". Emit every key the template binds on every item, sending null for a missing value rather than leaving the key out.
Paging with X-Helix-Next
Paging is driven by one response header. When the endpoint has more rows, it names the route for the next page, and the app requests it as the list scrolls near its end.
Set it with the same httpresponse Lava command you use for every other header:
{% httpresponse %}
[[ header key:'X-Helix-Next' ]]^/group-toolbox/members?groupId=GRxxxx&page=2[[ endheader ]]
{% endhttpresponse %}
What happens from there:
Detecting the last page
Your endpoint needs to know whether another page exists before it decides to send the header. The cheap way is to ask the database for one more row than the page size:
That avoids a separate count query, and it never sends a route that leads to an empty page. If an empty page does come back anyway, that is harmless: it appends nothing, and with no header on it, paging stops.
Next-page routes
A page request sends only what its route carries. It does not gather values from the page, and it does not run Hx.Confirm, Hx.Prompt, or Hx.Validator. So if page 1 was a search for "smi", the route for page 2 has to say so:
{% capture nextRoute %}^/group-toolbox/members?groupId={{ groupIdKey | EscapeDataString }}&q={{ search | EscapeDataString }}&page={{ page | Plus:1 }}{% endcapture %}
Escape every value you put in the route with EscapeDataString. The app decodes the query string before sending it, so an unescaped & or = in a search term splits it into the wrong parameters.
The short ^/endpoint-slug form works in the header when the list sits inside a Lava Application Content block with an Application set. The long form works everywhere, so it is the safer choice for an endpoint that more than one page uses.
Tuning the threshold
MAUI's own default of -1 never asks for more, which is why the app fills it in. A larger value starts the next request earlier, so it lands before the person reaches the end. Around half a page is a good starting point, for example RemainingItemsThreshold="10" with a page size of 20.
Make the page size large enough to more than fill the screen. Paging follows the scroll, so a first page that does not fill the list gives the person nothing to scroll toward the next one.
Search and filter
Point a search field at the list with items-replace, and each search swaps in a fresh first page:
The field's value is included automatically under its Hx.Id, so the endpoint reads it as QueryString.q. changed skips events where the text did not really change, and delay:400ms waits for a pause in typing before sending. See Triggers and Forms and Values.
What items-replace takes care of for you:
A list whose own request can fire more than once, for example with Hx.Trigger="appear" to refresh when the person returns, should declare items-replace too. With items-append, each repeat adds a second copy of page 1 below the first.
Empty view
A CollectionView shows its EmptyView whenever it has no items, which would include the moment before the first response arrives. The app holds the EmptyView back while the first request for a list is in flight, so the person does not see "No results" flash up before the results do.
Indicators
Put Hx.Indicator on the list to cover page fetches. A page request is fired by the list itself, so the list is its initiator and its Hx.Indicator is the one shown. Put one on the search field to cover searches. Both can point at the same element. See Indicators and Errors.
An indicator overlaid on the list, resting at Opacity="0", works well. The app raises it to full opacity during a request and restores the resting value after.
Avoid using a spinner inside the list's Footer as the indicator. An id is only usable while its element is on screen, and the list creates and removes its footer as it scrolls, so the indicator is not reliably there to show.
Errors
A list cannot hold an error notification among its items, so an error never goes into an items target. It goes to the first of these that applies:
A failed page leaves every item already loaded in place, and the next route stays as it was, so scrolling again retries that page.
Declare an Hx.Notification slot on anything with a list in it. A list usually fills a Grid row, and an error placed beside an element that sits directly in a Grid overlaps the first cell. Declaring the slot on the container covers the search field and the list at once, because it inherits.
Worked example
A leader opens a group and sees its active members, can search by name, and scrolls through the whole roster 20 at a time. The page hosts a Lava Application Content block whose Application is the Group Toolbox application (slug group-toolbox), and the page is opened with a GroupId page parameter carrying the group's IdKey.
The block's Initial Template
{% assign groupIdKey = PageParameter.GroupId | EscapeDataString %}
The members endpoint's Code Template
{% assign groupIdKey = QueryString.groupId %}
{% assign groupId = groupIdKey | FromIdHash | Default:0 %}
{% assign search = QueryString.q | Default:'' %}
{% assign page = QueryString.page | AsInteger | Default:1 %}
{% assign pageSize = 20 %}
{% assign skip = page | Minus:1 | Times:pageSize %}
{% assign take = pageSize | Plus:1 %}
{% sql groupid:'{{ groupId }}' personid:'{{ CurrentPerson.Id | Default:0 }}' search:'{{ search }}' skip:'{{ skip }}' take:'{{ take }}' %}
DECLARE @term nvarchar(100) = LTRIM( RTRIM( @search ) );
SELECT p.[NickName] + ' ' + p.[LastName] AS [Name], r.[Name] AS [Role]
FROM [GroupMember] gm
INNER JOIN [Person] p ON p.[Id] = gm.[PersonId]
INNER JOIN [GroupTypeRole] r ON r.[Id] = gm.[GroupRoleId]
WHERE gm.[GroupId] = @groupid
AND gm.[IsArchived] = 0
AND gm.[GroupMemberStatus] = 1
AND EXISTS (
SELECT 1
FROM [GroupMember] leader
INNER JOIN [GroupTypeRole] lr ON lr.[Id] = leader.[GroupRoleId]
WHERE leader.[GroupId] = @groupid
AND leader.[PersonId] = @personid
AND leader.[IsArchived] = 0
AND lr.[IsLeader] = 1 )
AND ( @term = '' OR p.[NickName] LIKE @term + '%' OR p.[LastName] LIKE @term + '%' )
ORDER BY p.[LastName], p.[NickName], gm.[Id]
OFFSET CAST( @skip AS int ) ROWS FETCH NEXT CAST( @take AS int ) ROWS ONLY;
{% endsql %}
{% assign rowCount = results | Size %}
{% if rowCount > pageSize %}
{% capture nextRoute %}^/group-toolbox/members?groupId={{ groupIdKey | EscapeDataString }}&q={{ search | EscapeDataString }}&page={{ page | Plus:1 }}{% endcapture %}
{% httpresponse %}
[[ header key:'X-Helix-Next' ]]{{ nextRoute }}[[ endheader ]]
{% endhttpresponse %}
{% endif %}
[
{% for row in results limit:pageSize %}
{
"Name": {{ row.Name | ToJSON }},
"Role": {{ row.Role | ToJSON }}
}{% unless forloop.last %},{% endunless %}
{% endfor %}
]
Endpoint settings: slug members, HTTP method GET, Content Type application/json, and Sql in Enabled Lava Commands. Grant Execute to the people who should be able to call it, and see Security before you do.
How the pieces fit:
Limitations
For more patterns, see Tips and Tricks and Limitations.