Working with Updates
Within Litho, lists are implemented using the Lazy Collection API.
A Lazy Collection is updated by changing the prop/state values from which it is generated.
For correctness and performance, ensure the following:
- Provide Child identity for all children that can be updated.
- Avoid unnecessary layouts by making sure that props of children don't unnecessarily change.
The above two points are detailed in the following sections.
Child identityβ
Each child in a Lazy Collection has an id
, which is assigned by the Developer.
The id
is used to determine how a collection changed after an update: children may have been added, removed, changed position, or had their content updated. Since an id
is unique to a child, it is used to match children across changes to create minimal changeset and apply content update animations, and so on.
If no id
parameter is assigned by a Developer, then it's generated using the child's position and type (this is only sufficient for static content).
child(Header()) // generated id is "Header:0"
If the list contents can be updated, then provide unique ids that are consistent across renders.
In the following example, there are two Text
components, one is added conditionally based on shouldShowGreeting
, the other is unconditional. If generated ids are used, the first child is always assigned the id "Text:0", so the id would be inconsistent across renders. On Android, you can fix this by manually assigning an id
.
if (shouldShowGreeting.value) {
child(id = "greeting", component = Text("Greetings!"))
}
child(id = "title", component = Text("Title"))
Content backed by lists should be added using the children(..)
function: specify an id
lambda that generates an id for each item:
children(items = friends, id = { it.id }) { Text(it.name) }
It is unacceptable to generate ids using a simple incrementing variable as items may change positions. Instead, use an id from the data model.
An id
must be unique and immutable. Breaking this contract may lead to occasional IndexOutOfBoundsException
exceptions during layout.
Avoiding unnecessary layoutsβ
During a list update, if a child with the same id
is found in the old and new lists then the Lazy Collection automatically detects if the content has changed. If an update has not occurred, the subtree will be re-used as-is, otherwise it is re-created.
Content changes are detected by checking the equality of the component's props. If any component prop does not implement an equals()
then it cannot be reused. It is common for classes to not implement equals()
such as Drawables, Lambdas, and data models. A little bit more work is required to avoid unnecessary layouts when these are used.
The behaviour will be functionally correct by default. If using props that do not provide an equals()
, the UI will still use the most up-to-date prop and state values. However, there will be unnecessary layouts, which will impact performance.
Using classes without equals()
as Propsβ
If a component accepts a prop that does not implement an equals()
then it will never be reused.
Consider the following example:
class Name(val firstName: String, val secondName: String)
class NameComponent(val name: Name) : KComponent() {
override fun ComponentScope.render(): Component = Text("${name.firstName} ${name.secondName}")
}
class NameList_UnnecessaryUpdate : KComponent() {
override fun ComponentScope.render(): Component = LazyList {
child(NameComponent(Name("Mark", "Zuckerberg")))
}
}
In the above example, NameComponent
will be laid out on any update because it takes a prop of type Name
that does not implement an equals()
.
Unnecessary layouts can be avoided using two methods:
- Add an
equals()
to theName
class, such as by making it adata
class. This approach will not be possible if using an uncontrolled object provided by a framework. - Manually specify the dependencies that, if changed, should trigger an update.
class NameList_Fixed : KComponent() {
override fun ComponentScope.render(): Component = LazyList {
// Option 1. Convert to a prop with an `equals()` implementation
child(NameComponentWithEquals(NameWithEquals("Mark", "Zuckerberg")))
// Option 2. Manually specify dependencies (in this case empty)
child(deps = arrayOf()) { NameComponent(Name("Mark", "Zuckerberg")) }
}
}
By manually specifying the dependencies, NameComponent
is only laid out once and re-used whenever the 'example' is updated.
Consider the following example, which uses an Android Drawable
:
class Drawable_UnnecessaryUpdate : KComponent() {
override fun ComponentScope.render(): Component = LazyList {
child(Text("text", style = Style.background(ColorDrawable(Color.RED))))
}
}
In the above example, the Text component will be unnecessarily laid out on every render()
call because ColorDrawable does not implement an equals()
.
Here, unnecessary layouts can be avoided using two techniques:
- Use a drawable that implements equality such as Lithoβs ComparableColorDrawable.
- Manually specify the dependencies that, if changed, will trigger an update.
class Drawable_Fixed : KComponent() {
override fun ComponentScope.render(): Component = LazyList {
// Option 1. Use a `ComparableDrawable` wrapper
child(Text("text", style = Style.background(drawableColor(Color.RED))))
// Option 2. Manually specify dependencies (in this case empty).
child(deps = arrayOf()) { Text("text", style = Style.background(ColorDrawable(Color.RED))) }
}
}
Using lambdas as Propsβ
Lambdas do not provide an equals()
, for example, {} == {}
is false
. This means that using a lambda as a prop may cause unnecessary layouts.
To use a lambda in a Lazy Collection, wrap it in a useCallback
hook. This provides equality across layouts, allowing the component to be reused, and the lambda is guaranteed to use the latest captured prop and state values.
Consider the following example where a lambda is passed to a component:
class Lambda_UnnecessaryUpdate(val name: String) : KComponent() {
override fun ComponentScope.render(): Component = LazyList {
child(Text("text", style = Style.onClick { println("Hello $name") }))
}
}
In the above example, the Text will be laid out on any update to Example because the lambda props will never be equal. This can be fixed using the useCallback
hook:
class Lambda_Fixed(val name: String) : KComponent() {
override fun ComponentScope.render(): Component {
val callBack = useCallback { _: ClickEvent -> println("Hello $name") }
return LazyList { child(Text("text", style = Style.onClick(action = callBack))) }
}
}
Consider another example, this time displaying a shopping list:
class ShoppingList : KComponent() {
override fun ComponentScope.render(): Component {
val shoppingList = listOf("Apples", "Cheese", "Bread")
// Create a state containing the items that should be shown with a checkmark: β
// Initially empty
val checkedItems = useState { setOf<String>() }
// Create a callback to toggle the checkmark for an item
// States should always use immutable data, so a new Set is created
val toggleChecked = useCallback { item: String ->
checkedItems.update {
it.toMutableSet().apply { if (contains(item)) remove(item) else add(item) }.toSet()
}
}
return LazyList {
children(items = shoppingList, id = { it }) {
val isChecked = checkedItems.value.contains(it)
ShoppingListItem(it, isChecked, toggleChecked)
}
}
}
}
class ShoppingListItem(
private val item: String,
private val isChecked: Boolean,
private val toggleSelected: (String) -> Unit,
) : KComponent() {
override fun ComponentScope.render(): Component =
Text("${if (isChecked) "β" else "β"} $item", style = Style.onClick { toggleSelected(item) })
}
Each shopping list item requires a lambda to toggle a checkmark. If an unwrapped lambda was used, then no component would ever be reused as lambda does not provide an equals()
. Instead, wrap the lambda in a useCallback
hook. This provides equality across renders and ensure that changes are applied to the latest version of the checkedItems
state.