docs: add chapter on nested reactivity and iteration (#1920)

This commit is contained in:
Greg Johnston 2023-10-20 15:18:38 -04:00 committed by GitHub
parent 6abdca0597
commit b8ca8b7849
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 273 additions and 0 deletions

View File

@ -7,6 +7,7 @@
- [Dynamic Attributes](./view/02_dynamic_attributes.md)
- [Components and Props](./view/03_components.md)
- [Iteration](./view/04_iteration.md)
- [Iterating over More Complex Data](./view/04b_iteration.md)
- [Forms and Inputs](./view/05_forms.md)
- [Control Flow](./view/06_control_flow.md)
- [Error Handling](./view/07_errors.md)

View File

@ -0,0 +1,272 @@
# Iterating over More Complex Data with `<For/>`
This chapter goes into iteration over nested data structures in a bit
more depth. It belongs here with the other chapter on iteration, but feel
free to skip it and come back if youd like to stick with simpler subjects
for now.
## The Problem
I just said that the framework does not rerender any of the items in one of the
rows, unless the key has changed. This probably makes sense at first, but it can
easily trip you up.
Lets consider an example in which each of the items in our row is some data structure.
Imagine, for example, that the items come from some JSON array of keys and values:
```rust
#[derive(Debug, Clone)]
struct DatabaseEntry {
key: String,
value: i32,
}
```
Lets define a simple component that will iterate over the rows and display each one:
```rust
#[component]
pub fn App() -> impl IntoView {
// start with a set of three rows
let (data, set_data) = create_signal(vec![
DatabaseEntry {
key: "foo".to_string(),
value: 10,
},
DatabaseEntry {
key: "bar".to_string(),
value: 20,
},
DatabaseEntry {
key: "baz".to_string(),
value: 15,
},
]);
view! {
// when we click, update each row,
// doubling its value
<button on:click=move |_| {
set_data.update(|data| {
for row in data {
row.value *= 2;
}
});
// log the new value of the signal
logging::log!("{:?}", data.get());
}>
"Update Values"
</button>
// iterate over the rows and display each value
<For
each=data
key=|state| state.key.clone()
let:child
>
<p>{child.value}</p>
</For>
}
}
```
> Note the `let:child` syntax here. In the previous chapter we introduced `<For/>`
> with a `children` prop. We can actually create this value directly in the children
> of the `<For/>` component, without breaking out of the `view` macro: the `let:child`
> combined with `<p>{child.value}</p>` above is the equivalent of
>
> ```rust
> children=|child| view! { <p>{child.value}</p> }
> ```
When you click the `Update Values` button... nothing happens. Or rather:
the signal is updated, the new value is logged, but the `{child.value}`
for each row doesnt update.
Lets see: is that because we forgot to add a closure to make it reactive?
Lets try `{move || child.value}`.
...Nope. Still nothing.
Heres the problem: as I said, each row is only rerendered when the key changes.
Weve updated the value for each row, but not the key for any of the rows, so
nothing has rerendered. And if you look at the type of `child.value`, its a plain
`i32`, not a reactive `ReadSignal<i32>` or something. This means that even if we
wrap a closure around it, the value in this row will never update.
We have three possible solutions:
1. change the `key` so that it always updates when the data structure changes
2. change the `value` so that its reactive
3. take a reactive slice of the data structure instead of using each row directly
## Option 1: Change the Key
Each row is only rerendered when the key changes. Our rows above didnt rerender,
because the key didnt change. So: why not just force the key to change?
```rust
<For
each=data
key=|state| (state.key.clone(), state.value)
let:child
>
<p>{child.value}</p>
</For>
```
Now we include both the key and the value in the `key`. This means that whenever the
value of a row changes, `<For/>` will treat it as if its an entirely new row, and
replace the previous one.
### Pros
This is very easy. We can make it even easier by deriving `PartialEq`, `Eq`, and `Hash`
on `DatabaseEntry`, in which case we could just `key=|state| state.clone()`.
### Cons
**This is the least efficient of the three options.** Every time the value of a row
changes, it throws out the previous `<p>` element and replaces it with an entirely new
one. Rather than making a fine-grained update to the text node, in other words, it really
does rerender the entire row on every change, and this is expensive in proportion to how
complex the UI of the row is.
Youll notice we also end up cloning the whole data structure so that `<For/>` can hold
onto a copy of the key. For more complex structures, this can become a bad idea fast!
## Option 2: Nested Signals
If we do want that fine-grained reactivity for the value, one option is to wrap the `value`
of each row in a signal.
```rust
#[derive(Debug, Clone)]
struct DatabaseEntry {
key: String,
value: RwSignal<i32>,
}
```
`RwSignal<_>` is a “read-write signal,” which combines the getter and setter in one object.
Im using it here because its a little easier to store in a struct than separate getters
and setters.
```rust
#[component]
pub fn App() -> impl IntoView {
// start with a set of three rows
let (data, set_data) = create_signal(vec![
DatabaseEntry {
key: "foo".to_string(),
value: create_rw_signal(10),
},
DatabaseEntry {
key: "bar".to_string(),
value: create_rw_signal(20),
},
DatabaseEntry {
key: "baz".to_string(),
value: create_rw_signal(15),
},
]);
view! {
// when we click, update each row,
// doubling its value
<button on:click=move |_| {
data.with(|data| {
for row in data {
row.value.update(|value| *value *= 2);
}
});
// log the new value of the signal
logging::log!("{:?}", data.get());
}>
"Update Values"
</button>
// iterate over the rows and display each value
<For
each=data
key=|state| state.key.clone()
let:child
>
<p>{child.value}</p>
</For>
}
}
```
This version works! And if you look in the DOM inspector in your browser, youll
see that unlike in the previous version, in this version only the individual text
nodes are updated. Passing the signal directly into `{child.value}` works, as
signals do keep their reactivity if you pass them into the view.
Note that I changed the `set_data.update()` to a `data.with()`. `.with()` is the
non-cloning way of accessing a signals value. In this case, we are only updating
the internal values, not updating the list of values: because signals maintain their
own state, we dont actual need to update the `data` signal at all, so the immutable
`.with()` is fine here.
> In fact, this version doesnt update `data`, so the `<For/>` is essentially a static
> list as in the last chapter, and this could just be a plain iterator. But the `<For/>`
> is useful if we want to add or remove rows in the future.
### Pros
This is the most efficient option, and fits directly with the rest of the mental model
of the framework: values that change over time are wrapped in signals so the interface
can respond to them.
### Cons
Nested reactivity can be cumbersome if youre receiving data from an API or another
data source you dont control, and you dont want to create a different struct wrapping
each field in a signal.
## Option 3: Memoized Slices
Leptos provides a primitive called [`create_memo`](https://docs.rs/leptos/latest/leptos/fn.create_memo.html),
which creates a derived computation that only triggers a reactive update when its value
has changed.
This allows you to create reactive values for subfields of a larger data structure,
without needing to wrap the fields of that structure in signals.
Most of the application can remain the same as the initial (broken) version, but the `<For/>`
will be updated to this:
```rust
<For
each=move || data().into_iter().enumerate()
key=|(_, state)| state.key.clone()
children=move |(index, _)| {
let value = create_memo(move |_| {
data.with(|data| data[index].value)
});
view! {
<p>{value}</p>
}
}
/>
```
Youll notice a few differences here:
- we convert the `data` signal into an enumerated iterator
- we use the `children` prop explicitly, to make it easier to run some non-`view` code
- we define a `value` memo and use that in the view. This `value` field doesnt actually
use the `child` being passed into each row. Instead, it uses the index and reaches back
into the original `data` to get the value.
Every time `data` changes, now, each memo will be recalculated. If its value has changed,
it will update its text node, without rerendering the whole row.
## Pros
We get the same fine-grained reactivity of the signal-wrapped version, without needing to
wrap the data in signals.
## Cons
Its a bit more complex to set up this memo-per-row inside the `<For/>` loop rather than
using nested signals. Note also that while memos memoize their reactive changes, the same
calculation does need to re-run to check the value every time, so nested reactive signals
will still be more efficient for pinpoint updates here.