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><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><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><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
- Chain a DataSource refetch - refetch after a successful mutation
- Control cache invalidation - restrict which DataSource caches refresh after a write
- Configure Tree data format and mapping - map backend fields to Tree IDs and parents
- Follow a List to the bottom - keep chat-style feeds anchored to the latest item