Lists

The cart on the Stores page has a quantity input in every row. Change one quantity and the row you are typing in has to stay: the input keeps focus, a row animation keeps playing, the scroll position holds. The first version of that list many people write rebuilds every row on every change, and the symptom is an input that loses focus after one keystroke.

The same problem shows up in search results, order history tables, and chat threads. A list is where a small mistake costs the most, because a row that is rebuilt instead of updated loses its DOM, its focus, its scroll position, and its animation, and does so once per row.

This guide is organized by what you are doing with the list. The Components and JSX page introduces For and Repeat; this page is about using them well. The examples use the cart's line items:

type CartItem = {
id: string;
name: string;
price: number;
quantity: number;
savedForLater: boolean;
};

Render rows from an array

For maps an array to rows and reuses a row when its item comes back:

import { For } from "solid-js";
function CartLines(props: { items: CartItem[] }) {
return (
<ul>
<For each={props.items} fallback={<li>Your cart is empty</li>}>
{(item, index) => (
<li>
{index() + 1}. {item.name} × {item.quantity}
</li>
)}
</For>
</ul>
);
}

Pass three items and three rows render, numbered 1 to 3; pass an empty array and the fallback row shows. The callback runs once per item that For has not seen before, and its JSX is created once. item is the item itself. index is an accessor, because the same item can move to another position without being recreated; read index() where the position is displayed.

"Seen before" means object identity. If the array on the next update contains the same object, the row is kept and moved if needed; if it contains a new object, the row is disposed and a new one is created. Everything else on this page follows from that rule.

Deep diveWhat a rebuilt row loses

A row is a set of DOM nodes plus the reactive scopes that fill them. Disposing one removes the nodes, so focus, text selection, and an in-progress CSS animation go with them, and it disposes the scopes, so any signal the row component created starts over in the new row. The list itself does not know a row was rebuilt for the same record; [UNSTABLE_LIST_IDENTITY] exists because attribution compares the disposed and created items field by field and reports when they match.


Keep row identity across updates

The identity rule bites when data comes from a server. A refetch returns new objects for the same records, so a list keyed by identity rebuilds every row:

const items = createMemo(() => api.cartItems()); // new objects on every refetch
<For each={items()}>{(item) => <CartLine item={item} />}</For>;

Refetch after a quantity change and every row is rebuilt, including the one the user is typing in. Development builds report this as [UNSTABLE_LIST_IDENTITY] when attribution is enabled: rows were disposed and recreated for items equal field-for-field to the ones they replaced. Two fixes, and the second is usually the better one.

Key by a field. Pass a key function, and For matches items by the key instead of by identity:

<For each={items()} keyed={(item) => item.id}>
{(item) => <CartLine item={item()} />}
</For>

In this mode the callback receives accessors for both the item and the index. The row is kept when an item with the same id appears, and item() returns the newest object for that id, so the row's JSX updates to the new fields without being recreated.

Load into a store. A store reconciles each response into the same proxies by id, so the objects themselves keep their identity:

const [items] = createStore(async () => api.cartItems(), [] as CartItem[]);
<For each={items}>{(item) => <CartLine item={item} />}</For>;

Now item is a store proxy, item.quantity is a fine-grained read, and a refetch that changes one field of one record updates one text node. Nothing about For changed; the data stopped producing new objects. This is the shape to reach for whenever the list is edited or refetched, which is most lists.


Edit a row

With the list in a store, editing is a write at a path. The habit to unlearn from immutable state libraries is replacing the array to change one item:

const [items, setItems] = createStore<CartItem[]>([]);
// Avoid: a new object for the edited item, so its row is rebuilt
function saveForLater(id: string) {
setItems((draft) =>
draft.map((item) =>
item.id === id ? { ...item, savedForLater: true } : item
)
);
}
// Prefer: change the one property on the draft
function saveForLater(id: string) {
setItems((draft) => {
const item = draft.find((item) => item.id === id);
if (item) item.savedForLater = true;
});
}

Run the Avoid version while the cursor is in that row's quantity input and the input loses focus, because the row was torn down and a new one created; every reader of the old item object runs as well. Development builds flag it as [IMMUTABLE_UPDATE_IN_STORE] when a path is replaced by a container whose leaves are mostly unchanged. In the Prefer version only the readers of that item's savedForLater property run; the row is not recreated, the list is not diffed, and the other rows are untouched.

Adding and removing follow the same rule:

setItems((draft) => {
draft.push({ id, name, price, quantity: 1, savedForLater: false });
});
setItems((draft) => draft.filter((item) => item.id !== id));

Returning a new array from the setter is fine for removal; the surviving items are the same proxies, so their rows are kept. Use reconcile when the whole list arrives fresh from the server and should be diffed in.


Filter and sort

Derive the visible list; do not store it:

const [filter, setFilter] = createSignal<"all" | "active" | "saved">("all");
const [sortBy, setSortBy] = createSignal<"added" | "name" | "price">("added");
const visible = createMemo(() => {
const list = items.filter((item) =>
filter() === "all"
? true
: filter() === "saved"
? item.savedForLater
: !item.savedForLater
);
return list.sort((a, b) =>
sortBy() === "name"
? a.name.localeCompare(b.name)
: sortBy() === "price"
? a.price - b.price
: 0
);
});
<For each={visible()}>{(item) => <CartLine item={item} />}</For>;

Switch the sort to price and the rows move into their new order; no row is recreated. visible() is a new array on every change, and that is fine. The items inside it are the same proxies as before, so For keeps every row and reorders or hides them.

A derived array is not a rebuilt list

A derived array costs a diff; a new set of objects costs a rebuild. Those are different things, and only the second is a problem.

When the derived list is large and read by several consumers, createProjection produces a store-shaped result instead of an array, so readers of individual rows do not subscribe to the whole list; Stores shows the shape.


Select a row

Highlighting the selected row in an order history table is the classic performance trap: the direct version re-runs every row when the selection changes.

const [selectedId, setSelectedId] = createSignal<string>();
// Avoid: every row reads selectedId(), so every row re-evaluates on each change
<tr class={{ selected: selectedId() === order.id }}>...</tr>;
// Prefer: a store keyed by id, so each row reads only its own key
const [selected, setSelected] = createStore<Record<string, boolean>>({});
function select(id: string) {
setSelected((draft) => {
for (const key of Object.keys(draft)) delete draft[key];
draft[id] = true;
});
}
<tr class={{ selected: selected[order.id] }} onClick={() => select(order.id)}>
...
</tr>;

Run the Avoid version with a few dozen rows and nothing is wrong; with a few thousand, every click re-evaluates every row's class binding. In the Prefer version, when the selection moves from one row to another, exactly two rows update: the one that lost the key and the one that gained it. The same store handles multi-select without changes; leave the other keys in place instead of deleting them.

When the selected id already lives somewhere else, such as a route parameter, derive the store from it with createProjection instead of writing it by hand:

const isSelected = createProjection<Record<string, boolean>>((draft) => {
for (const key of Object.keys(draft)) delete draft[key];
draft[params.id] = true;
}, {});

Render a window over a large list

For creates a row per item. For a list that is long enough to be a problem, such as an account's full order history, render only the rows in view.

Repeat renders a range of positions over a store, without slicing the array:

import { Repeat, createSignal, createStore } from "solid-js";
function OrderHistory(props: { orders: Store<Order[]> }) {
const [from, setFrom] = createSignal(0);
const size = 50;
return (
<ul
onScroll={(event) =>
setFrom(Math.floor(event.currentTarget.scrollTop / ROW_HEIGHT))
}
>
<Repeat
from={from()}
count={Math.min(size, props.orders.length - from())}
>
{(index) => <li>{props.orders[index].number}</li>}
</Repeat>
</ul>
);
}

Scroll and the fifty rows in view are the only rows in the DOM. Each row reads props.orders[index] directly from the store, so a change to one order updates one row. When from moves, rows whose index is still in range are kept, rows that left are disposed, and rows for new indexes are created.

Repeat is positional

A Repeat row shows whatever is at its index, so it fits logs, tables, and grids over store data. It does not fit lists where a row must follow its item as the list reorders; use For for those.

For a full virtualizer with variable heights, use a library; the pattern above is what those libraries build on.


Show a loading and an empty state

The two are different states and use different tools. fallback on For is the empty state: a settled list with no items. A Loading boundary is the not-yet state:

<Loading fallback={<ListSkeleton />}>
<ul>
<For each={items} fallback={<li>Your cart is empty</li>}>
{(item) => <CartLine item={item} />}
</For>
</ul>
</Loading>

The skeleton shows until the first response; the "Your cart is empty" row shows when that response is empty. A refetch does not bring the skeleton back; the rows stay, and isPending(() => items.length) reports the wait if you want to dim them.


Common problems

Rows lose focus or animation when the list changes

The items are new objects on each update. Key by a field or load into a store; see Keep row identity across updates.

A row shows the wrong index after reordering

The callback captured index() once instead of reading it in JSX. Read index() inside the JSX expression so it stays reactive, or use keyed={false} when the row should be bound to its position rather than its item.

Editing one item rebuilds the whole list

The list is an array of plain objects in a signal, and the edit replaced the array or the object. Move the list into a store and mutate the draft.

items.push(item) does nothing

A store proxy drops writes made outside its setter. Write through setItems((draft) => { draft.push(item); }).


Recap

  • For keeps a row when the same object comes back and rebuilds it when a new object appears; every other rule follows from that.
  • Load server lists into a store with createStore(async () => ..., []), or pass keyed={(item) => item.id}, so a refetch does not rebuild rows.
  • Edit one property on the setter's draft; a map with a spread creates a new object and a new row.
  • Derive filtered and sorted views with a memo; a new array of the same proxies is a diff, not a rebuild.
  • Keep selection in a store keyed by id so a change touches two rows, not every row.
  • Use Repeat for a window over a large positional list, and For when rows must follow their items.
  • Use fallback on For for the empty state and a Loading boundary for the not-yet state.

Next steps

Last updated: 9/12/26, 9:23 AMEdit this pageReport an issue with this page