Preserve collection state across data refreshes

Use preserveStateOnNextDataRefresh() before a backend mutation when the next Tree, List, or Table refresh should keep the user's current view state.

When a collection is backed by a DataSource, a successful insert, update, or delete often ends with a refetch. By default, that refreshed data can reset viewport state. The one-shot API below tells the next refresh to reconcile the new rows with the current view: matching IDs keep their preserved state, scroll position is preserved unless you ask for a target, and inserts can scroll the first inserted row into view without flashing through an empty or reset collection.

Tree

<App
  var.selectedNode="{null}"
  var.lastAction="Ready"
  var.statusColors="{{
    Writing: { background: '#f59e0b', label: 'white' },
    Refreshing: { background: '#3b82f6', label: 'white' },
    Ready: { background: '#10b981', label: 'white' }
  }}">

  <DataSource
    id="projectNodes"
    url="/api/project-nodes"
    method="GET" />

  <APICall
    id="insertNode"
    method="post"
    url="/api/project-nodes"
    invalidates="{[]}"
    onSuccess="(node) => {
      selectedNode = node.id;
      lastAction = 'Inserted ' + node.name;
      projectNodes.refetch();
    }" />

  <APICall
    id="updateNode"
    method="put"
    url="/api/project-nodes/13"
    invalidates="{[]}"
    onSuccess="(node) => {
      lastAction = 'Updated ' + node.name + ' and kept it in view';
      projectNodes.refetch();
    }" />

  <APICall
    id="deleteNode"
    method="delete"
    url="/api/project-nodes/20"
    invalidates="{[]}"
    onSuccess="(node) => {
      selectedNode = selectedNode === node.id ? null : selectedNode;
      lastAction = 'Deleted ' + node.name;
      projectNodes.refetch();
    }" />

  <script>
    function projectTreeData(nodes) {
      if (!nodes) {
        return nodes;
      }
      return nodes.map(node => ({ ...node }));
    }

    function addEngineeringTask() {
      projectTree.preserveStateOnNextDataRefresh({ operation: 'insert' });
      insertNode.execute();
    }

    function renameGateway() {
      projectTree.preserveStateOnNextDataRefresh({
        operation: 'update',
        scrollTarget: 13
      });
      updateNode.execute();
    }

    function removeVendorRenewals() {
      projectTree.preserveStateOnNextDataRefresh({ operation: 'delete' });
      deleteNode.execute();
    }
  </script>

  <VStack gap="$space-4">
    <HStack verticalAlignment="center" gap="$space-2">
      <Button
        label="Insert under Engineering"
        onClick="addEngineeringTask()" />
      <Button
        label="Update API gateway"
        onClick="renameGateway()" />
      <Button
        label="Delete vendor renewals"
        themeColor="attention"
        onClick="removeVendorRenewals()" />
      <SpaceFiller />
      <Badge
        value="Writing"
        colorMap="{statusColors}"
        when="{
          insertNode.inProgress || updateNode.inProgress
            || deleteNode.inProgress}" />
      <Badge
        value="Refreshing"
        colorMap="{statusColors}"
        when="{projectNodes.isRefetching}" />
      <Badge
        value="Ready"
        colorMap="{statusColors}"
        when="{
          !insertNode.inProgress && !updateNode.inProgress &&
            !deleteNode.inProgress && !projectNodes.isRefetching
        }" />
    </HStack>

    <Text variant="secondary">{lastAction}</Text>

    <Tree
      id="projectTree"
      testId="project-tree"
      height="360px"
      data="{projectTreeData(projectNodes.value)}"
      dataRefreshMode="reset"
      defaultExpanded="{[1, 2, 3, 4, 6, 10, 21]}"
      selectedValue="{selectedNode}"
      onSelectionDidChange="({ newNode }) => selectedNode = newNode?.id ?? null">
      <property name="itemTemplate">
        <HStack
          testId="tree-node-{$item.id}"
          verticalAlignment="center"
          gap="$space-2">
          <Icon name="{$item.hasChildren ? 'folder' : 'code'}" />
          <Text>{$item.name}</Text>
          <Badge value="changed" when="{$item.id === selectedNode}" />
        </HStack>
      </property>
    </Tree>
  </VStack>
</App>
Tree refresh after insert, update, and delete
<App
  var.selectedNode="{null}"
  var.lastAction="Ready"
  var.statusColors="{{
    Writing: { background: '#f59e0b', label: 'white' },
    Refreshing: { background: '#3b82f6', label: 'white' },
    Ready: { background: '#10b981', label: 'white' }
  }}">

  <DataSource
    id="projectNodes"
    url="/api/project-nodes"
    method="GET" />

  <APICall
    id="insertNode"
    method="post"
    url="/api/project-nodes"
    invalidates="{[]}"
    onSuccess="(node) => {
      selectedNode = node.id;
      lastAction = 'Inserted ' + node.name;
      projectNodes.refetch();
    }" />

  <APICall
    id="updateNode"
    method="put"
    url="/api/project-nodes/13"
    invalidates="{[]}"
    onSuccess="(node) => {
      lastAction = 'Updated ' + node.name + ' and kept it in view';
      projectNodes.refetch();
    }" />

  <APICall
    id="deleteNode"
    method="delete"
    url="/api/project-nodes/20"
    invalidates="{[]}"
    onSuccess="(node) => {
      selectedNode = selectedNode === node.id ? null : selectedNode;
      lastAction = 'Deleted ' + node.name;
      projectNodes.refetch();
    }" />

  <script>
    function projectTreeData(nodes) {
      if (!nodes) {
        return nodes;
      }
      return nodes.map(node => ({ ...node }));
    }

    function addEngineeringTask() {
      projectTree.preserveStateOnNextDataRefresh({ operation: 'insert' });
      insertNode.execute();
    }

    function renameGateway() {
      projectTree.preserveStateOnNextDataRefresh({
        operation: 'update',
        scrollTarget: 13
      });
      updateNode.execute();
    }

    function removeVendorRenewals() {
      projectTree.preserveStateOnNextDataRefresh({ operation: 'delete' });
      deleteNode.execute();
    }
  </script>

  <VStack gap="$space-4">
    <HStack verticalAlignment="center" gap="$space-2">
      <Button
        label="Insert under Engineering"
        onClick="addEngineeringTask()" />
      <Button
        label="Update API gateway"
        onClick="renameGateway()" />
      <Button
        label="Delete vendor renewals"
        themeColor="attention"
        onClick="removeVendorRenewals()" />
      <SpaceFiller />
      <Badge
        value="Writing"
        colorMap="{statusColors}"
        when="{
          insertNode.inProgress || updateNode.inProgress
            || deleteNode.inProgress}" />
      <Badge
        value="Refreshing"
        colorMap="{statusColors}"
        when="{projectNodes.isRefetching}" />
      <Badge
        value="Ready"
        colorMap="{statusColors}"
        when="{
          !insertNode.inProgress && !updateNode.inProgress &&
            !deleteNode.inProgress && !projectNodes.isRefetching
        }" />
    </HStack>

    <Text variant="secondary">{lastAction}</Text>

    <Tree
      id="projectTree"
      testId="project-tree"
      height="360px"
      data="{projectTreeData(projectNodes.value)}"
      dataRefreshMode="reset"
      defaultExpanded="{[1, 2, 3, 4, 6, 10, 21]}"
      selectedValue="{selectedNode}"
      onSelectionDidChange="({ newNode }) => selectedNode = newNode?.id ?? null">
      <property name="itemTemplate">
        <HStack
          testId="tree-node-{$item.id}"
          verticalAlignment="center"
          gap="$space-2">
          <Icon name="{$item.hasChildren ? 'folder' : 'code'}" />
          <Text>{$item.name}</Text>
          <Badge value="changed" when="{$item.id === selectedNode}" />
        </HStack>
      </property>
    </Tree>
  </VStack>
</App>

List

<App var.selectedTickets="" var.lastListAction="Ready">
  <DataSource
    id="tickets"
    url="/api/tickets"
    method="GET" />

  <APICall
    id="insertTicket"
    method="post"
    url="/api/tickets"
    invalidates="{[]}"
    onSuccess="(ticket) => {
      lastListAction = 'Inserted ' + ticket.title;
      tickets.refetch();
    }" />

  <APICall
    id="updateTicket"
    method="put"
    url="/api/tickets/ticket-18"
    invalidates="{[]}"
    onSuccess="(ticket) => {
      lastListAction = 'Updated ' + ticket.title + ' and kept it in view';
      tickets.refetch();
    }" />

  <APICall
    id="deleteTicket"
    method="delete"
    url="/api/tickets/ticket-28"
    invalidates="{[]}"
    onSuccess="(ticket) => {
      lastListAction = 'Deleted ' + ticket.title;
      tickets.refetch();
    }" />

  <script>
    function ticketListData(tickets) {
      if (!tickets) {
        return tickets;
      }
      return tickets.map(ticket => ({ ...ticket }));
    }

    function addTicket() {
      ticketList.preserveStateOnNextDataRefresh({ operation: 'insert' });
      insertTicket.execute();
    }

    function renameTicket() {
      ticketList.preserveStateOnNextDataRefresh({
        operation: 'update',
        scrollTarget: 'ticket-18'
      });
      updateTicket.execute();
    }

    function removeTicket() {
      ticketList.preserveStateOnNextDataRefresh({ operation: 'delete' });
      deleteTicket.execute();
    }
  </script>

  <VStack gap="$space-4">
    <HStack verticalAlignment="center" gap="$space-2">
      <Button label="Insert ticket" onClick="addTicket()" />
      <Button label="Update ticket 18" onClick="renameTicket()" />
      <Button 
        label="Delete ticket 28" 
        themeColor="attention" 
        onClick="removeTicket()" 
      />
    </HStack>

    <Text variant="secondary">{lastListAction}</Text>
    <Text>Selected tickets: {selectedTickets || "(none)"}</Text>

    <List
      id="ticketList"
      testId="ticket-list"
      height="300px"
      data="{ticketListData(tickets.value)}"
      dataRefreshMode="reset"
      rowsSelectable="true"
      fixedItemSize="true"
      onSelectionDidChange="
        (items) => selectedTickets = items.map(item => item.id).join(', ')
      ">
      <HStack height="36px" verticalAlignment="center" gap="$space-2">
        <Text>{$item.title}</Text>
        <Badge value="selected" when="{$isSelected}" />
        <SpaceFiller />
        <Text variant="secondary">{$item.owner}</Text>
      </HStack>
    </List>
  </VStack>
</App>
List refresh after insert, update, and delete
<App var.selectedTickets="" var.lastListAction="Ready">
  <DataSource
    id="tickets"
    url="/api/tickets"
    method="GET" />

  <APICall
    id="insertTicket"
    method="post"
    url="/api/tickets"
    invalidates="{[]}"
    onSuccess="(ticket) => {
      lastListAction = 'Inserted ' + ticket.title;
      tickets.refetch();
    }" />

  <APICall
    id="updateTicket"
    method="put"
    url="/api/tickets/ticket-18"
    invalidates="{[]}"
    onSuccess="(ticket) => {
      lastListAction = 'Updated ' + ticket.title + ' and kept it in view';
      tickets.refetch();
    }" />

  <APICall
    id="deleteTicket"
    method="delete"
    url="/api/tickets/ticket-28"
    invalidates="{[]}"
    onSuccess="(ticket) => {
      lastListAction = 'Deleted ' + ticket.title;
      tickets.refetch();
    }" />

  <script>
    function ticketListData(tickets) {
      if (!tickets) {
        return tickets;
      }
      return tickets.map(ticket => ({ ...ticket }));
    }

    function addTicket() {
      ticketList.preserveStateOnNextDataRefresh({ operation: 'insert' });
      insertTicket.execute();
    }

    function renameTicket() {
      ticketList.preserveStateOnNextDataRefresh({
        operation: 'update',
        scrollTarget: 'ticket-18'
      });
      updateTicket.execute();
    }

    function removeTicket() {
      ticketList.preserveStateOnNextDataRefresh({ operation: 'delete' });
      deleteTicket.execute();
    }
  </script>

  <VStack gap="$space-4">
    <HStack verticalAlignment="center" gap="$space-2">
      <Button label="Insert ticket" onClick="addTicket()" />
      <Button label="Update ticket 18" onClick="renameTicket()" />
      <Button 
        label="Delete ticket 28" 
        themeColor="attention" 
        onClick="removeTicket()" 
      />
    </HStack>

    <Text variant="secondary">{lastListAction}</Text>
    <Text>Selected tickets: {selectedTickets || "(none)"}</Text>

    <List
      id="ticketList"
      testId="ticket-list"
      height="300px"
      data="{ticketListData(tickets.value)}"
      dataRefreshMode="reset"
      rowsSelectable="true"
      fixedItemSize="true"
      onSelectionDidChange="
        (items) => selectedTickets = items.map(item => item.id).join(', ')
      ">
      <HStack height="36px" verticalAlignment="center" gap="$space-2">
        <Text>{$item.title}</Text>
        <Badge value="selected" when="{$isSelected}" />
        <SpaceFiller />
        <Text variant="secondary">{$item.owner}</Text>
      </HStack>
    </List>
  </VStack>
</App>

Table

<App var.selectedOrders="" var.lastTableAction="Ready">
  <DataSource
    id="orders"
    url="/api/orders"
    method="GET" />

  <APICall
    id="insertOrder"
    method="post"
    url="/api/orders"
    invalidates="{[]}"
    onSuccess="(order) => {
      lastTableAction = 'Inserted ' + order.name;
      orders.refetch();
    }" />

  <APICall
    id="updateOrder"
    method="put"
    url="/api/orders/order-16"
    invalidates="{[]}"
    onSuccess="(order) => {
      lastTableAction = 'Updated ' + order.name + ' and kept it in view';
      orders.refetch();
    }" />

  <APICall
    id="deleteOrder"
    method="delete"
    url="/api/orders/order-18"
    invalidates="{[]}"
    onSuccess="(order) => {
      lastTableAction = 'Deleted ' + order.name;
      orders.refetch();
    }" />

  <script>
    function orderTableData(orders) {
      if (!orders) {
        return orders;
      }
      return orders.map(order => ({ ...order }));
    }

    function addOrder() {
      orderTable.preserveStateOnNextDataRefresh({ operation: 'insert' });
      insertOrder.execute();
    }

    function renameOrder() {
      orderTable.preserveStateOnNextDataRefresh({
        operation: 'update',
        scrollTarget: 'order-16'
      });
      updateOrder.execute();
    }

    function removeOrder() {
      orderTable.preserveStateOnNextDataRefresh({ operation: 'delete' });
      deleteOrder.execute();
    }
  </script>

  <VStack gap="$space-4">
    <HStack verticalAlignment="center" gap="$space-2">
      <Button label="Insert order" onClick="addOrder()" />
      <Button label="Update order 16" onClick="renameOrder()" />
      <Button 
        label="Delete order 18" 
        themeColor="attention" 
        onClick="removeOrder()" 
      />
    </HStack>

    <Text variant="secondary">{lastTableAction}</Text>
    <Text>Selected orders: {selectedOrders || "(none)"}</Text>

    <Table
      id="orderTable"
      testId="order-table"
      height="320px"
      data="{orderTableData(orders.value)}"
      dataRefreshMode="reset"
      rowsSelectable="true"
      rowHeight="36"
      onSelectionDidChange="
        (items) => selectedOrders = items.map(item => item.id).join(', ')
      ">
      <Column header="Order" bindTo="name" canSort="true" />
      <Column header="Owner" bindTo="owner" canSort="true" />
      <Column header="Status" bindTo="status" />
    </Table>
  </VStack>
</App>
Table refresh after insert, update, and delete
<App var.selectedOrders="" var.lastTableAction="Ready">
  <DataSource
    id="orders"
    url="/api/orders"
    method="GET" />

  <APICall
    id="insertOrder"
    method="post"
    url="/api/orders"
    invalidates="{[]}"
    onSuccess="(order) => {
      lastTableAction = 'Inserted ' + order.name;
      orders.refetch();
    }" />

  <APICall
    id="updateOrder"
    method="put"
    url="/api/orders/order-16"
    invalidates="{[]}"
    onSuccess="(order) => {
      lastTableAction = 'Updated ' + order.name + ' and kept it in view';
      orders.refetch();
    }" />

  <APICall
    id="deleteOrder"
    method="delete"
    url="/api/orders/order-18"
    invalidates="{[]}"
    onSuccess="(order) => {
      lastTableAction = 'Deleted ' + order.name;
      orders.refetch();
    }" />

  <script>
    function orderTableData(orders) {
      if (!orders) {
        return orders;
      }
      return orders.map(order => ({ ...order }));
    }

    function addOrder() {
      orderTable.preserveStateOnNextDataRefresh({ operation: 'insert' });
      insertOrder.execute();
    }

    function renameOrder() {
      orderTable.preserveStateOnNextDataRefresh({
        operation: 'update',
        scrollTarget: 'order-16'
      });
      updateOrder.execute();
    }

    function removeOrder() {
      orderTable.preserveStateOnNextDataRefresh({ operation: 'delete' });
      deleteOrder.execute();
    }
  </script>

  <VStack gap="$space-4">
    <HStack verticalAlignment="center" gap="$space-2">
      <Button label="Insert order" onClick="addOrder()" />
      <Button label="Update order 16" onClick="renameOrder()" />
      <Button 
        label="Delete order 18" 
        themeColor="attention" 
        onClick="removeOrder()" 
      />
    </HStack>

    <Text variant="secondary">{lastTableAction}</Text>
    <Text>Selected orders: {selectedOrders || "(none)"}</Text>

    <Table
      id="orderTable"
      testId="order-table"
      height="320px"
      data="{orderTableData(orders.value)}"
      dataRefreshMode="reset"
      rowsSelectable="true"
      rowHeight="36"
      onSelectionDidChange="
        (items) => selectedOrders = items.map(item => item.id).join(', ')
      ">
      <Column header="Order" bindTo="name" canSort="true" />
      <Column header="Owner" bindTo="owner" canSort="true" />
      <Column header="Status" bindTo="status" />
    </Table>
  </VStack>
</App>

Key points

Call the API before the refresh starts: preserveStateOnNextDataRefresh(...) marks only the next observed data refresh. In a real app, call it immediately before the mutation that will eventually refetch the collection data.

dataRefreshMode="reset" keeps normal behavior by default: The examples leave each collection in reset mode, then opt into preservation for each insert, update, or delete. The API call automatically makes the next refresh behave like preserve-state.

Insert intent can choose the viewport for you: { operation: 'insert' } compares the old and refreshed data, finds the first inserted source ID, and scrolls it into view only if it is outside the current visible area.

Delete intent preserves scroll: { operation: 'delete' } keeps the scroll position because the deleted row may no longer exist as a valid target. Table also clamps pagination if a deletion removes the current page.

Use scrollTarget when the operation has a known focus: The update buttons pass explicit row IDs so the refreshed row remains visible after the backend returns the changed data.

Stable IDs are required: Preservation works by matching refreshed source rows by idKey for List and Table, or idField for Tree. Set those props when your backend uses custom identity field names.

Refresh animation is intentionally separate from preservation. The default behavior focuses on keeping data and state correct without adding layout motion; highlight-style row animation can be layered on later when your design calls for it.


See also