Abstract¶
In this section, we learn how to create and work with lists in the Common Tools runtime. We’ll especially focus on displaying lists and manipulating them.
Introduction¶
Whatever it is you are building, you’ll very likely need to display a list at some point. We’ll go over how to do that within recipes.
Lists are essential for displaying collections of data—whether it’s a todo list, a contact list, or a feed of messages.
Important Concept: Bidirectional Binding
Before we dive in, know that CommonTools components support bidirectional binding with the $ prefix ($value, $checked, etc.). This automatically updates cells when users interact with components, often eliminating the need for handlers! We’ll introduce this concept as we go, but keep in mind: if you’re just syncing UI ↔ data with no additional logic, bidirectional binding is usually simpler than using handlers.
Our First List¶
Let’s start simple: we’ll create a list of our friends. We’ll make an array of 5
names using Default<> (from the previous chapter).
We’ll need the following imports for this recipe:
/// <cts-enable />
import {
Default,
h,
recipe,
UI,
} from "commontools";Now we define our state interface with Default<> to hold our list of friends:
1 2 3 4 5 6 7 8 9 10 11 12interface FriendListState { names: Default< { name: string }[], [ { name: "Alice" }, { name: "Bob" }, { name: "Charlie" }, { name: "Diana" }, { name: "Evan" }, ] >; }
Lines 1-11 define our state interface. The Default<> type tells the runtime to
create a Cell that holds an array of objects (each with a name property) and
initialize it with our five friend names.
With our state defined, we can now display the names in the recipe’s [UI]. We’ll use
the .map() function to iterate over each name and render it as a list item:
1 2 3 4 5<ul> {state.names.map((friend) => ( <li>{friend.name}</li> ))} </ul>
The .map() function (line 2) iterates over each friend object in our array.
For each friend, we create an <li> element (line 3) that displays the friend’s
name property.
View complete code
/// <cts-enable />
import { Default, recipe, UI } from "commontools";
interface FriendListState {
names: Default<
{ name: string }[],
[
{ name: "Alice" },
{ name: "Bob" },
{ name: "Charlie" },
{ name: "Diana" },
{ name: "Evan" },
]
>;
}
export default recipe<FriendListState>("making lists - simple", (state) => {
return {
[UI]: (
<div>
<h2>My Friends</h2>
<ul>
{/* Note: key is not needed for Common Tools but linters require it */}
{state.names.map((friend, index) => (
<li key={index}>{friend.name}</li>
))}
</ul>
</div>
),
names: state.names,
};
});
We’ve demonstrated the following concepts:
How to define a state interface with
Default<>for an array of objectsUse
.map()to iterate over the array and render list itemsAccess object properties in JSX templates
Create a simple unordered list in the
[UI]Export Cells in the return statement
Removing Items¶
In this section we’ll build a feature to remove friends from our list. We’ll add an onclick listener to the list item and our handler will delete the item from the list.
First, we need to import the handler function and Cell type:
import {
type Cell,
Default,
h,
handler,
recipe,
UI,
} from "commontools";Next, we’ll create a handler that removes an item from the array. Note we currently have a bug in the runtime and therefore we reconstruct the array.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22const removeItem = handler< unknown, { names: Cell<{ name: string }[]>; friend: { name: string } } >( (_, { names, friend }) => { const currentNames = names.get(); // Ideal code (once bug is fixed): // const filtered = currentNames.filter((f, i) => // !names.key(i).equals(friend as any) // ); // names.set(filtered); // Current workaround using reduce with object reconstruction: const filtered = currentNames.reduce((acc, _, i) => { if (!names.key(i).equals(friend as any)) { acc.push({ name: currentNames[i].name }); } return acc; }, [] as { name: string }[]); names.set(filtered); }, );
Lines 1-4 define the handler signature. The second type parameter specifies that
we need the names Cell and the actual friend object to remove (not an
index).
Line 6 gets the current array from the cell.
Lines 7-11 show the ideal code that would work once the runtime bug is fixed—a
simple .filter() with .key(i).equals() comparison.
Lines 14-19 contain the current workaround using .reduce() to build a new
array that excludes the friend we want to remove.
Line 15 uses .key(i).equals(friend) to check if the Cell reference at index
i matches the friend object we want to remove.
Line 16 explicitly reconstructs each object as { name: currentNames[i].name }
to strip any internal proxy symbols.
Line 20 sets the Cell with the filtered array.
Detailed explanation - Why reconstruct the array?
When you call .map() on a Cell array, each item returned is actually a
Cell reference maintains its connection to the Cell system. These proxies have
internal symbols like Symbol("toCell") and Symbol("toOpaqueRef") attached to
them and a path property that specifies its location in the Cell structure.
We must currently reconstruct the objects to avoid a runtime bug, this is what we do in the acc.push line below.
// ❌ This doesn't work reliably due to a bug in the system:
currentNames.filter((f) => f !== friend)
// ✅ This works correctly:
currentNames.reduce((acc, _, i) => {
if (!names.key(i).equals(friend as any)) {
acc.push({ name: currentNames[i].name });
}
return acc;
}, [])If we directly push currentNames[i], the proxy symbols get copied into the new
array. When we call names.set(filtered), the Cell system gets confused by
these symbols and throws errors. By explicitly creating a new object with just
the properties we care about, we strip away all the internal metadata and create
a clean object.
Line 13 sets the Cell with our filtered array.
Now we can attach this handler to each list item:
1 2 3 4 5 6 7<ul> {state.names.map((friend) => ( <li onclick={removeItem({ names: state.names, friend })}> {friend.name} </li> ))} </ul>
Line 3 attaches the removeItem handler to each list item. We pass in
state.names Cell and the friend object itself (not an index). This object is
the proxy returned from .map(), which the handler can compare using
.key(i).equals(friend).
When you deploy this recipe, clicking on any name will remove it from the list. The UI automatically updates because the Cell changes.
View complete code
/// <cts-enable />
import { type Cell, Default, handler, recipe, UI } from "commontools";
interface FriendListState {
names: Default<
{ name: string }[],
[
{ name: "Alice" },
{ name: "Bob" },
{ name: "Charlie" },
{ name: "Diana" },
{ name: "Evan" },
]
>;
}
const removeItem = handler<
unknown,
{ names: Cell<{ name: string }[]>; friend: { name: string } }
>(
// TODO(@ellyxir): note this code SHOULD work but there's a bug
// in the current system, so we have an alternative right below it
// that is quite ugly but reconstructs the list without celllinks and symbols
// (_, { names, friend }) => {
// const currentNames = names.get();
// const filtered = currentNames.filter((f, i) =>
// !names.key(i).equals(friend as any)
// );
// names.set(filtered);
// },
(_, { names, friend }) => {
const currentNames = names.get();
const filtered = currentNames.reduce((acc, _, i) => {
if (!names.key(i).equals(friend as any)) {
acc.push({ name: currentNames[i].name });
}
return acc;
}, [] as { name: string }[]);
names.set(filtered);
},
);
export default recipe<FriendListState>(
"making lists - with remove",
(state) => {
return {
[UI]: (
<div>
<h2>My Friends</h2>
<ul>
{/* Note: key is not needed for Common Tools but linters require it */}
{state.names.map((friend, index) => (
<li
key={index}
onclick={removeItem({ names: state.names, friend })}
>
{friend.name}
</li>
))}
</ul>
</div>
),
names: state.names,
};
},
);
We’ve demonstrated:
How to create a handler that modifies array state
Use
onclickon list items to make them interactivePass object references instead of indices to handlers (preferred pattern)
Use
.key(i).equals(object)to compare Cell referencesRemove items from a Cell array using
.reduce()with object reconstructionWhy object reconstruction is necessary to strip proxy symbols from
.map()results
Editing Items¶
Now we can change the names of our friends. We’ll use regular <input> elements
to keep things basic.
Two Approaches:
Simple approach (bidirectional binding): Use
$valueto automatically sync the input with the cellHandler approach: Handle the Enter key press to update the value
We’ll demonstrate the handler approach here for learning purposes, but in practice, bidirectional binding (approach 1) is often simpler.
The handler checks if the key is Enter and, if so, we grab the string entered by the user and update it in the names list. We’ll be passing the index so we know which position in the array to update.
First, let’s create a handler that updates a name in the array:
1 2 3 4 5 6 7 8 9 10 11const editItem = handler<any, { names: Cell<string[]>, index: number }>( (event, { names, index }) => { if (event?.key === "Enter") { const newValue = event?.target?.value; if (newValue !== undefined) { const currentNames = names.get(); names.set(currentNames.toSpliced(index, 1, newValue)); } } }, );
Line 3 checks if the Enter key was pressed. We only update the array when the user presses Enter, not on every keystroke.
Detailed explanation - The Keyboard Event
The onkeydown event provides a KeyboardEvent object with information about the
key press:
const editItem = handler<any, { names: Cell<string[]>, index: number }>(
(event, { names, index }) => {
console.log("Event.type:", event?.type); // "keydown"
console.log("Event.key:", event?.key); // "Enter", "a", "Shift", etc.
console.log("Event.code:", event?.code); // "Enter", "KeyA", "ShiftLeft", etc.
console.log("Event.target.value:", event?.target?.value); // Current input value
console.log("Modifier keys:", {
alt: event?.altKey,
ctrl: event?.ctrlKey,
meta: event?.metaKey,
shift: event?.shiftKey,
});
if (event?.key === "Enter") {
const newValue = event?.target?.value;
if (newValue !== undefined) {
const currentNames = names.get();
names.set(currentNames.toSpliced(index, 1, newValue));
}
}
},
);Useful properties:
event.key- The actual key value (“Enter”, “a”, “Escape”, “ArrowUp”)event.code- The physical key code (“Enter”, “KeyA”, “Escape”, “ArrowUp”)event.target.value- The current text in the input fieldModifier keys - Detect Ctrl+Enter, Alt+S, etc. for keyboard shortcuts
You could extend this to:
Save on Enter, cancel on Escape
Different behavior with Ctrl+Enter vs plain Enter
Navigate between inputs with arrow keys
Line 4 gets the new value from the input field using event.target.value.
Line 7 uses .toSpliced(index, 1, newValue) to replace the item at index with
newValue. The second parameter 1 means “remove 1 item”, and the third
parameter is what to insert in its place.
Now let’s update our UI to use input fields instead of plain text:
1 2 3 4 5 6 7 8 9 10<ul> {state.names.map((name, index) => ( <li> <input value={name} onkeydown={editItem({ names: state.names, index })} /> </li> ))} </ul>
Line 4 creates an <input> element with the current name as its value.
Line 6 attaches the onkeydown event listener, which fires whenever a key is
pressed while the input is focused. Our handler checks for the Enter key.
When you deploy this recipe, you can click on any input field, type a new name, and press Enter to update it.
Combining Edit and Remove¶
Now let’s add back the remove functionality from earlier, but this time using a
button next to each input. The onclick event works the same way on buttons as
it did on list items:
1 2 3 4 5 6<li> <input value={name} onkeydown={editItem({ names: state.names, index })} /> <button type="button" onclick={removeItem({ names: state.names, index })}> Delete </button> </li>
The removeItem handler is the same one we created earlier - we can reuse it
with the button’s onclick event.
Alternative: Bidirectional Binding Approach¶
As mentioned earlier, for simple value updates, bidirectional binding is often
simpler. Here’s how the editing section would look using $value:
// No edit handler needed!
<li>
<ct-input $value={state.names[index]} />
<button type="button" onclick={removeItem({ names: state.names, index })}>
Delete
</button>
</li>With $value, the input automatically updates the cell when the user types -
no need for an editItem handler or Enter key checking! This is the recommended
approach for simple value updates.
View complete code
/// <cts-enable />
import { type Cell, Default, handler, recipe, UI } from "commontools";
interface FriendListState {
names: Default<string[], ["Alice", "Bob", "Charlie", "Diana", "Evan"]>;
}
const removeItem = handler<unknown, { names: Cell<string[]>; index: number }>(
(_, { names, index }) => {
const currentNames = names.get();
names.set(currentNames.toSpliced(index, 1));
},
);
const editItem = handler<any, { names: Cell<string[]>; index: number }>(
(event, { names, index }) => {
if (event?.key === "Enter") {
const newValue = event?.target?.value;
if (newValue !== undefined) {
const currentNames = names.get();
names.set(currentNames.toSpliced(index, 1, newValue));
}
}
},
);
export default recipe<FriendListState>("making lists - with edit", (state) => {
return {
[UI]: (
<div>
<h2>My Friends</h2>
<ul>
{/* Note: key is not needed for Common Tools but linters require it */}
{state.names.map((name, index) => (
<li key={index}>
<input
value={name}
onkeydown={editItem({ names: state.names, index })}
/>
<button
type="button"
onclick={removeItem({ names: state.names, index })}
>
Delete
</button>
</li>
))}
</ul>
</div>
),
names: state.names,
};
});
We’ve demonstrated:
How to use
<input>elements to edit list itemsDetect specific key presses using the
onkeydownevent andevent.keyUpdate specific items in an array using
.toSpliced()with a replacement valueCombine multiple interactive features (editing and removing) in one recipe
Reuse handlers across different UI elements (list items and buttons)
Reordering Lists with Keybindings¶
Some friends are more important than others, so let’s allow users to reorder the
list. Instead of adding an up and down button next to each item, we’ll introduce
the <ct-keybind> component that lets us register keyboard shortcuts.
We’ll use Ctrl+Up Arrow to move an item up in the list, and Ctrl+Down Arrow to move it down. First, we need to track which item is currently selected.
In order to keep track of which name is currently selected, we’ll add a new field to our state interface:
1 2 3 4interface FriendListState { names: Default<string[], ["Alice", "Bob", "Charlie", "Diana", "Evan"]>; selectedIndex: Default<number, 0>; }
Line 3 adds a selectedIndex field to track which list item is currently selected.
We initialize it to 0 (the first item).
Next, we need a handler to update the selected index when a user clicks on a
list item. We pass in the Cell which we’ll be updating and the index of the
name that the user just clicked on:
1 2 3 4 5const selectItem = handler<unknown, { selectedIndex: Cell<number>, index: number }>( (_, { selectedIndex, index }) => { selectedIndex.set(index); }, );
This handler simply updates the selectedIndex cell when a list item is
clicked.
Now we can create a handler to move items up or down. Instead of creating two
separate handlers, we’ll use a direction parameter:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21const moveItem = handler< any, { names: Cell<string[]>; selectedIndex: Cell<number>; direction: "UP" | "DOWN"; } >((_, { names, selectedIndex, direction }) => { const index = selectedIndex.get(); const currentNames = names.get(); const offset = direction === "UP" ? -1 : 1; const newIndex = index + offset; if (newIndex >= 0 && newIndex < currentNames.length) { const newNames = [...currentNames]; [newNames[index], newNames[newIndex]] = [newNames[newIndex], newNames[index]]; names.set(newNames); selectedIndex.set(newIndex); } }, );
Lines 1-8 define the handler with a direction parameter that can only be
"UP" or "DOWN". TypeScript will enforce this. The parameters are formatted
vertically for readability.
Line 11 converts the direction to an offset: -1 to move up, 1 to move down.
Line 14 checks if the new position is valid (not out of bounds).
Line 16 uses array destructuring to swap the current item with the item at the new position.
Line 18 updates the selected index to follow the moved item.
Now we can use the <ct-keybind> component to register our keyboard shortcuts.
The <ct-keybind> component listens for keyboard events at the document level,
so it works regardless of which element has focus.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18<ct-keybind ctrl key="ArrowUp" onct-keybind={moveItem({ names: state.names, selectedIndex: state.selectedIndex, direction: "UP" })} /> <ct-keybind ctrl key="ArrowDown" onct-keybind={moveItem({ names: state.names, selectedIndex: state.selectedIndex, direction: "DOWN" })} />
Line 2 specifies that the Ctrl key must be held.
Line 3 specifies which key to listen for (ArrowUp or ArrowDown).
Lines 4-8 attach our handler to the onct-keybind event, passing both Cells and
direction: "UP" for the up arrow.
Lines 13-17 do the same for the down arrow, passing direction: "DOWN".
More about ct-keybind
The <ct-keybind> component supports many options for creating keyboard
shortcuts:
Modifier Keys
You can require any combination of modifier keys:
// Ctrl+S
<ct-keybind ctrl key="s" onct-keybind={save()} />
// Cmd+K (Meta key is Cmd on Mac, Win on Windows)
<ct-keybind meta key="k" onct-keybind={openSearch()} />
// Shift+Enter
<ct-keybind shift key="Enter" onct-keybind={submitWithShift()} />
// Ctrl+Shift+P
<ct-keybind ctrl shift key="p" onct-keybind={commandPalette()} />
// Alt+Arrow keys
<ct-keybind alt key="ArrowLeft" onct-keybind={navigateBack()} />Key Codes vs Key Values
You can use either key or code:
// Use 'key' for the character value
<ct-keybind ctrl key="o" onct-keybind={openFile()} />
// Use 'code' for the physical key position
<ct-keybind ctrl code="KeyO" onct-keybind={openFile()} />The difference: key gives you the character (affected by keyboard layout),
while code gives you the physical key position (always the same regardless of
layout).
Behavior Options
// Prevent default browser behavior
<ct-keybind
ctrl
key="s"
prevent-default
onct-keybind={save()}
/>
// Allow when focused in input fields (default: disabled in inputs)
<ct-keybind
ctrl
key="Enter"
ignore-editable={false}
onct-keybind={submit()}
/>
// Allow key repeat (when key is held down)
<ct-keybind
key="ArrowUp"
allow-repeat
onct-keybind={scrollUp()}
/>
// Stop event from bubbling
<ct-keybind
key="Escape"
stop-propagation
onct-keybind={closeModal()}
/>Common Key Names
Letter keys:
"a","b","c", etc.Arrow keys:
"ArrowUp","ArrowDown","ArrowLeft","ArrowRight"Special keys:
"Enter","Escape","Tab","Space"Function keys:
"F1","F2", etc.
The onct-keybind event receives a detail object containing the event and key
information. :::
Finally, we need to update our list items to be selectable:
1 2 3 4<li onclick={selectItem({ selectedIndex: state.selectedIndex, index })}> <input value={name} onkeydown={editItem({ names: state.names, index })} /> <button type="button" onclick={removeItem({ names: state.names, index })}>Delete</button> </li>
Line 1 adds the onclick handler to track which item is selected.
When you deploy this recipe, you can click on any list item to select it, then press Ctrl+Up or Ctrl+Down to move it in the list.
View complete code
/// <cts-enable />
import { type Cell, Default, handler, recipe, UI } from "commontools";
interface FriendListState {
names: Default<string[], ["Alice", "Bob", "Charlie", "Diana", "Evan"]>;
selectedIndex: Default<number, 0>;
}
const removeItem = handler<unknown, { names: Cell<string[]>; index: number }>(
(_, { names, index }) => {
const currentNames = names.get();
names.set(currentNames.toSpliced(index, 1));
},
);
const editItem = handler<any, { names: Cell<string[]>; index: number }>(
(event, { names, index }) => {
if (event?.key === "Enter") {
const newValue = event?.target?.value;
if (newValue !== undefined) {
const currentNames = names.get();
names.set(currentNames.toSpliced(index, 1, newValue));
}
}
},
);
const selectItem = handler<
unknown,
{ selectedIndex: Cell<number>; index: number }
>(
(_, { selectedIndex, index }) => {
selectedIndex.set(index);
},
);
const moveItem = handler<
any,
{
names: Cell<string[]>;
selectedIndex: Cell<number>;
direction: "UP" | "DOWN";
}
>((_, { names, selectedIndex, direction }) => {
const index = selectedIndex.get();
const currentNames = names.get();
const offset = direction === "UP" ? -1 : 1;
const newIndex = index + offset;
if (newIndex >= 0 && newIndex < currentNames.length) {
const newNames = [...currentNames];
[newNames[index], newNames[newIndex]] = [
newNames[newIndex],
newNames[index],
];
names.set(newNames);
selectedIndex.set(newIndex);
}
});
export default recipe<FriendListState>(
"making lists - with reorder",
(state) => {
return {
[UI]: (
<div>
<h2>My Friends</h2>
<p>Click to select, Ctrl+Up/Down to reorder</p>
<ct-keybind
ctrl
key="ArrowUp"
onct-keybind={moveItem({
names: state.names,
selectedIndex: state.selectedIndex,
direction: "UP",
})}
/>
<ct-keybind
ctrl
key="ArrowDown"
onct-keybind={moveItem({
names: state.names,
selectedIndex: state.selectedIndex,
direction: "DOWN",
})}
/>
<ul>
{/* Note: key is not needed for Common Tools but linters require it */}
{state.names.map((name, index) => (
<li
key={index}
onclick={selectItem({
selectedIndex: state.selectedIndex,
index,
})}
>
<input
value={name}
onkeydown={editItem({ names: state.names, index })}
/>
<button
type="button"
onclick={removeItem({ names: state.names, index })}
>
Delete
</button>
</li>
))}
</ul>
</div>
),
names: state.names,
selectedIndex: state.selectedIndex,
};
},
);
We’ve demonstrated:
How to track selected items with a cell
Use array destructuring to swap elements in an array
Register keyboard shortcuts with the
<ct-keybind>componentListen for modifier keys (Ctrl) combined with arrow keys
Use TypeScript string literal types to create type-safe direction parameters
Update multiple cells in response to a single event (names and selectedIndex)
Adding Items¶
Of course we love more friends! We’ll add an input field to add new friends. Users can type in a new friend name and it will be added to our friends list.
Let’s create a handler that adds the new name to our list when the user presses
Enter. The handler will receive a keyboard event from the input field (just like
we saw with the editing section earlier) and the
names Cell.
1 2 3 4 5 6 7 8 9 10 11const addFriend = handler<any, { names: Cell<string[]> }>( (event, { names }) => { if (event?.key === "Enter") { const name = event?.target?.value?.trim(); if (name) { const currentNames = names.get(); names.set([...currentNames, name]); } } }, );
Line 3 checks if the Enter key was pressed.
Line 4 gets the value directly from the input field and removes any extra
whitespace with .trim().
Line 5 checks that the name isn’t empty (after trimming).
Line 7 uses the spread operator (...currentNames) to create a new array with
all the existing names, then adds the new name at the end.
Now we can add the input field to our UI:
1 2 3 4 5 6<div> <input onkeydown={addFriend({ names: state.names })} placeholder="Add a new friend..." /> </div>
Line 3 attaches our addFriend handler to detect when Enter is pressed.
Line 4 adds placeholder text to show users what the input is for.
When you deploy this recipe, you can type a name and press Enter to add it to the bottom of your friends list.
View complete code
/// <cts-enable />
import { type Cell, Default, handler, recipe, UI } from "commontools";
interface FriendListState {
names: Default<string[], ["Alice", "Bob", "Charlie", "Diana", "Evan"]>;
selectedIndex: Default<number, 0>;
}
const removeItem = handler<unknown, { names: Cell<string[]>; index: number }>(
(_, { names, index }) => {
const currentNames = names.get();
names.set(currentNames.toSpliced(index, 1));
},
);
const editItem = handler<any, { names: Cell<string[]>; index: number }>(
(event, { names, index }) => {
if (event?.key === "Enter") {
const newValue = event?.target?.value;
if (newValue !== undefined) {
const currentNames = names.get();
names.set(currentNames.toSpliced(index, 1, newValue));
}
}
},
);
const selectItem = handler<
unknown,
{ selectedIndex: Cell<number>; index: number }
>(
(_, { selectedIndex, index }) => {
selectedIndex.set(index);
},
);
const moveItem = handler<
any,
{
names: Cell<string[]>;
selectedIndex: Cell<number>;
direction: "UP" | "DOWN";
}
>((_, { names, selectedIndex, direction }) => {
const index = selectedIndex.get();
const currentNames = names.get();
const offset = direction === "UP" ? -1 : 1;
const newIndex = index + offset;
if (newIndex >= 0 && newIndex < currentNames.length) {
const newNames = [...currentNames];
[newNames[index], newNames[newIndex]] = [
newNames[newIndex],
newNames[index],
];
names.set(newNames);
selectedIndex.set(newIndex);
}
});
const addFriend = handler<any, { names: Cell<string[]> }>(
(event, { names }) => {
if (event?.key === "Enter") {
const name = event?.target?.value?.trim();
if (name) {
const currentNames = names.get();
names.set([...currentNames, name]);
}
}
},
);
export default recipe<FriendListState>("making lists - with add", (state) => {
return {
[UI]: (
<div>
<h2>My Friends</h2>
<p>Click to select, Ctrl+Up/Down to reorder</p>
<ct-keybind
ctrl
key="ArrowUp"
onct-keybind={moveItem({
names: state.names,
selectedIndex: state.selectedIndex,
direction: "UP",
})}
/>
<ct-keybind
ctrl
key="ArrowDown"
onct-keybind={moveItem({
names: state.names,
selectedIndex: state.selectedIndex,
direction: "DOWN",
})}
/>
<div>
<input
onkeydown={addFriend({ names: state.names })}
placeholder="Add a new friend..."
/>
</div>
<ul>
{/* Note: key is not needed for Common Tools but linters require it */}
{state.names.map((name, index) => (
<li
key={index}
onclick={selectItem({
selectedIndex: state.selectedIndex,
index,
})}
>
<input
value={name}
onkeydown={editItem({ names: state.names, index })}
/>
<button
type="button"
onclick={removeItem({ names: state.names, index })}
>
Delete
</button>
</li>
))}
</ul>
</div>
),
names: state.names,
selectedIndex: state.selectedIndex,
};
});
We’ve demonstrated:
How adding to a
Cellarray will automatically add elements in a call to.map()
Linking Two Lists¶
You’ll often need to move data from one variable to another. In this section we’ll show how this is done by moving friends between two groups -- your personal friends and your work friends lists.
First, let’s create two separate lists and display them side by side. We’ll define a state interface with two arrays:
1 2 3 4interface FriendListsState { personalFriends: Default<string[], ["Alice", "Bob", "Charlie"]>; workFriends: Default<string[], ["Diana", "Evan"]>; }
Lines 1-4 define our state interface with two separate friend lists, each initialized with different names.
Now we can display both lists side by side using a flex layout:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18<div style="display: flex; gap: 2rem;"> <div> <h3>Personal Friends</h3> <ul> {state.personalFriends.map((name) => ( <li>{name}</li> ))} </ul> </div> <div> <h3>Work Friends</h3> <ul> {state.workFriends.map((name) => ( <li>{name}</li> ))} </ul> </div> </div>
Line 1 creates a flex container with a gap between the two lists.
Lines 3-9 display the personal friends list with its own heading.
Lines 11-17 display the work friends list with its own heading.
When you deploy this recipe, you’ll see two lists displayed next to each other.
Next, you can add functionality for editing, reordering, and removing items by
reusing the same handlers we’ve already built. Simply pass the appropriate Cell
(state.personalFriends or state.workFriends) to the handlers. When a Cell is
modified, the reactive system will update the appropriate list in the UI automatically.
View complete code
/// <cts-enable />
import { type Cell, Default, handler, recipe, UI } from "commontools";
interface FriendListsState {
personalFriends: Default<
{ name: string }[],
[
{ name: "Alice" },
{ name: "Bob" },
{ name: "Charlie" },
{ name: "David" },
{ name: "Emma" },
{ name: "Frank" },
{ name: "Grace" },
{ name: "Henry" },
{ name: "Iris" },
{ name: "Jack" },
]
>;
workFriends: Default<
{ name: string }[],
[
{ name: "Kevin" },
{ name: "Laura" },
{ name: "Mike" },
{ name: "Nancy" },
{ name: "Oscar" },
{ name: "Paula" },
{ name: "Quinn" },
{ name: "Rachel" },
{ name: "Steve" },
{ name: "Tina" },
{ name: "Uma" },
{ name: "Victor" },
]
>;
selectedItem: Default<
{ which_list: "work" | "personal"; index: number } | null,
null
>;
}
const selectItem = handler<
unknown,
{
selectedItem: Cell<
{ which_list: "work" | "personal"; index: number } | null
>;
which_list: "work" | "personal";
index: number;
}
>(
(_, { selectedItem, which_list, index }) => {
console.log("Selected:", which_list, index);
selectedItem.set({ which_list, index });
},
);
const moveItem = handler<
any,
{
personalFriends: Cell<{ name: string }[]>;
workFriends: Cell<{ name: string }[]>;
selectedItem: Cell<
{ which_list: "work" | "personal"; index: number } | null
>;
direction: "UP" | "DOWN";
}
>((event, { personalFriends, workFriends, selectedItem, direction }) => {
console.log("moveItem triggered, event:", event, "direction:", direction);
const selected = selectedItem.get();
if (selected === null) return;
const targetList = selected.which_list === "personal"
? personalFriends
: workFriends;
const currentNames = targetList.get();
const offset = direction === "UP" ? -1 : 1;
const newIndex = selected.index + offset;
if (newIndex >= 0 && newIndex < currentNames.length) {
// Reconstruct the array with swapped items
const newNames = currentNames.reduce((acc, _, i) => {
if (i === selected.index) {
acc.push({ name: currentNames[newIndex].name });
} else if (i === newIndex) {
acc.push({ name: currentNames[selected.index].name });
} else {
acc.push({ name: currentNames[i].name });
}
return acc;
}, [] as { name: string }[]);
targetList.set(newNames);
// Update selected index
selectedItem.set({ which_list: selected.which_list, index: newIndex });
}
});
const moveToList = handler<
any,
{
personalFriends: Cell<{ name: string }[]>;
workFriends: Cell<{ name: string }[]>;
selectedItem: Cell<
{ which_list: "work" | "personal"; index: number } | null
>;
targetList: "work" | "personal";
}
>(
(
event,
{ personalFriends, workFriends, selectedItem, targetList: target },
) => {
console.log("moveToList triggered, event:", event, "target:", target);
const selected = selectedItem.get();
if (selected === null) return;
// Don't move if already in target list
if (selected.which_list === target) return;
const sourceList = selected.which_list === "personal"
? personalFriends
: workFriends;
const destList = target === "personal" ? personalFriends : workFriends;
const sourceNames = sourceList.get();
const destNames = destList.get();
// Get the item to move
const itemToMove = sourceNames[selected.index];
// Remove from source list
const newSourceNames = sourceNames.reduce((acc, _, i) => {
if (i !== selected.index) {
acc.push({ name: sourceNames[i].name });
}
return acc;
}, [] as { name: string }[]);
sourceList.set(newSourceNames);
// Add to destination list at the same index, or at the end if too small
const targetIndex = Math.min(selected.index, destNames.length);
const newDestNames: { name: string }[] = [];
for (let i = 0; i < destNames.length; i++) {
if (i === targetIndex) {
newDestNames.push({ name: itemToMove.name });
}
newDestNames.push({ name: destNames[i].name });
}
// If targetIndex is at the end, append it
if (targetIndex === destNames.length) {
newDestNames.push({ name: itemToMove.name });
}
destList.set(newDestNames);
// Update selection to new location
selectedItem.set({ which_list: target, index: targetIndex });
},
);
export default recipe<FriendListsState>(
"making lists - two lists",
(state) => {
const moveUpHandler = moveItem({
personalFriends: state.personalFriends,
workFriends: state.workFriends,
selectedItem: state.selectedItem,
direction: "UP",
});
const moveDownHandler = moveItem({
personalFriends: state.personalFriends,
workFriends: state.workFriends,
selectedItem: state.selectedItem,
direction: "DOWN",
});
const moveToPersonalHandler = moveToList({
personalFriends: state.personalFriends,
workFriends: state.workFriends,
selectedItem: state.selectedItem,
targetList: "personal",
});
const moveToWorkHandler = moveToList({
personalFriends: state.personalFriends,
workFriends: state.workFriends,
selectedItem: state.selectedItem,
targetList: "work",
});
return {
[UI]: (
<div>
<h2>
Click to select, Ctrl+Up/Down to reorder, Ctrl+Left/Right to move
between lists
</h2>
<div style="margin-bottom: 1rem;">
<button type="button" onclick={moveUpHandler}>
▲ Move Up
</button>
<button type="button" onclick={moveDownHandler}>
▼ Move Down
</button>
<button type="button" onclick={moveToPersonalHandler}>
◀ Move to Personal
</button>
<button type="button" onclick={moveToWorkHandler}>
▶ Move to Work
</button>
</div>
<ct-keybind
ctrl
key="ArrowUp"
onct-keybind={moveUpHandler}
/>
<ct-keybind
ctrl
key="ArrowDown"
onct-keybind={moveDownHandler}
/>
<ct-keybind
ctrl
key="ArrowLeft"
onct-keybind={moveToPersonalHandler}
/>
<ct-keybind
ctrl
key="ArrowRight"
onct-keybind={moveToWorkHandler}
/>
<div style="display: flex; gap: 2rem;">
<div>
<h3>Personal Friends</h3>
<ul>
{/* Note: key is not needed for Common Tools but linters require it */}
{state.personalFriends.map((friend, index) => (
<li
key={index}
onclick={selectItem({
selectedItem: state.selectedItem,
which_list: "personal",
index,
})}
>
{friend.name}
</li>
))}
</ul>
</div>
<div>
<h3>Work Friends</h3>
<ul>
{/* Note: key is not needed for Common Tools but linters require it */}
{state.workFriends.map((friend, index) => (
<li
key={index}
onclick={selectItem({
selectedItem: state.selectedItem,
which_list: "work",
index,
})}
>
{friend.name}
</li>
))}
</ul>
</div>
</div>
</div>
),
personalFriends: state.personalFriends,
workFriends: state.workFriends,
selectedItem: state.selectedItem,
};
},
);
We’ve demonstrated:
How to define multiple arrays in a state interface using
Default<>Display multiple lists in the same UI
Use CSS flexbox for side-by-side layout
Reuse handlers across different Cells