Rendering Lists

Static lists

There are multiple ways we can render a list in Sycamore. If all our data is static, we can simply use Rust’s iterators to map from Vec<Data> to a Vec<View> which can then be turned into a View. For example:

struct Todo {
    task: String,
}

let todos = vec![
    Todo { task: "Learn Rust".to_string() },
    Todo { task: "Learn Sycamore".to_string() },
];

let todos_view = todos.into_iter().map(|todo| view! {
    li { (todo.task) }
}).collect::<Vec<View>>();

view! {
    ul {
        (todos_view)
    }
}

This works great for static data. However, it’s not so great for dynamic data. If we turn list_view into a closure that returns a View, we would get a dynamically updated list view, but with a problem.

Every time our reactive data is changed, the closure will recreate all the views for each individual item. Most of the time, however, we are only updating a small part of our list, such as changing the task field of a single todo, or adding a new todo item to the end of the list. In these cases, we don’t want to re-render the entire list, just change the part that needs to be updated.

For this reason, Sycamore introduces two utility components called Keyed and Indexed.

Indexed lists

An indexed list will automatically diff the previous list with the new list value to find out which items have changed, and then update them automatically.

#[derive(Clone, PartialEq, Eq)]
struct Todo {
    task: String
}

let todos = create_signal(vec![...]);

view! {
    ul {
        Indexed(
            list=todos,
            view=|todo| view! {
                li { (todo.task) }
            },
        )
    }
}

However, this still has one final issue. If we re-order the items in the list, Indexed has no way of knowing which item is which from the old list. To solve this, we can use keyed lists instead.

Keyed lists

A keyed list diffs the previous list with the new list by using a unique key for each item. This means that we must associate with each item an unique key. For instance, we can use a simple incrementing counter, or generate random UUIDs using the uuid crate.

#[derive(Clone, PartialEq, Eq)]
struct Todo {
    task: String,
    // An unique id associated with each todo.
    // This must be unique, otherwise unexpected things can happen (but not UB).
    id: u32,
}

let todos = create_signal(vec![...]);

view! {
    ul {
        Keyed(
            list=todos,
            view=|todo| view! {
                li { (todo.task) }
            },
            key=|todo| todo.id,
        )
    }
}

Nested Reactivity

One common pattern is called nested reactivity. This basically means putting signals inside signals. For example, if we are building a todo app, we might want to allow editing the todo task after the todo has already been created.

We might therefore want to change our Todo struct to look like:

#[derve(Clone, PartialEq, Eq)]
struct Todo {
    task: Signal<String>,
    id: u32,
}

We can then update the list like so:

let todos = create_signal(vec![...]);

let new_task = "Cook Dinner".to_string();
// Signal::update is similar to Signal::set but gives you a &mut.
// This allows us to avoid cloning the entire Vec.
todos.update(|todos| todos.push(Todo {
    task: create_signal(new_task),
    // Generate a unique key by using an incrementing counter or using UUIDs.
    id: get_unique_id(),
}));

However, this creates a memory leak when we remove items from our list. The reason is because create_signal allocates a new signal in the current reactive scope, and is not deallocated until the parent reactive scope is disposed. In this case, we are calling create_signal in our main App component so the memory will not be deallocated until the app is closed.

We must therefore manually dispose of the signal once it is removed. This is easily done by adding a on_cleanup function inside of the Keyed component.

view! {
    ul {
        Keyed(
            list=todos,
            view=|todo| {
                // Dispose of the signal when this item is removed.
                on_cleanup(move || todo.task.dispose());
                view! { ... }
            },
            key=|todo| todo.id,
        )
    }
}

The on_cleanup function can be called in any reactive scope and registers a callback when the surrounding scope is disposed. In this case, Keyed creates a new reactive scope for each item so calling on_cleanup inside the view closure will register the callback when the item is removed from the list.