Question
I am using Android's ListAdapter with DiffUtil in a Kotlin RecyclerView. The first call to submitList() displays every artist correctly. However, when I call submitList() again after changing properties on an existing Artist, the changed row is not rebound immediately.
The row displays the new value only after I scroll it off-screen and back, which causes onBindViewHolder() to run. Calling notifyDataSetChanged() after submitList() updates the row, but I want ListAdapter and DiffUtil to detect the change without forcing a full refresh.
class ArtistsAdapter : ListAdapter<Artist, ArtistsAdapter.ViewHolder>(ArtistsDiff()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(parent.inflate(R.layout.item_artist))
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(getItem(position))
}
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
fun bind(artist: Artist) {
itemView.artistDetails.text = "${artist.artistAlbums} Albums • ${artist.artistTracks} Tracks"
itemView.artistName.text = artist.artistCover
itemView.artistCoverImage.loadURL(artist.artistCover)
}
}
}
musicViewModel.getAllArtists().observe(this, Observer { artists ->
artists?.let { artistAdapter.submitList(it) }
})
class ArtistsDiff : DiffUtil.ItemCallback<Artist>() {
override fun areItemsTheSame(oldItem: Artist?, newItem: Artist?): Boolean {
return oldItem?.artistId == newItem?.artistId
}
override fun areContentsTheSame(oldItem: Artist?, newItem: Artist?): Boolean {
return oldItem == newItem
}
}
Why does ListAdapter not update the changed item, and how should I submit updates so that DiffUtil can rebind only the affected row?
Short Answer
ListAdapter calculates differences between an old snapshot and a new snapshot. To detect an update, submit a new list containing a new Artist object for each changed artist. Do not mutate an object that belongs to the list already submitted to the adapter. With immutable models—commonly Kotlin data class models—and copy(), DiffUtil can identify the same item by ID and rebind it because its contents changed.
Concept
ListAdapter uses DiffUtil to compare the list it displayed previously with the list passed to the next submitList() call.
DiffUtil.ItemCallback answers two separate questions:
-
Is this the same logical item?
- Usually compare a stable ID such as
artistId. - This lets the adapter recognize that artist 42 is still artist 42.
- Usually compare a stable ID such as
-
Did that item's displayed content change?
- Compare the properties that determine what the row shows.
- If the answer is false, the adapter schedules a bind for that row.
The important rule is that the old list must remain an accurate snapshot. If code changes an existing Artist instance in place, both the adapter's old list and the new list can point to that same changed object. DiffUtil then has no reliable old value to compare against.
For example, if the previous object originally had artistTracks = 10, and you mutate that same object to 11, the "old" object visible to the diff may already say 11. Comparing old and new can incorrectly look unchanged.
This is why notifyDataSetChanged() appears to fix the screen: it bypasses the diff and forces every visible row to bind. It is a broad refresh, though, and loses the precise update and animation benefits that ListAdapter provides.
Mental Model
Think of DiffUtil as a spot-the-difference game using two photographs.
- The first submitted list is the before photograph.
- The next submitted list is the after photograph.
areItemsTheSame()matches people in the two photographs by their ID badge.areContentsTheSame()checks whether each matched person changed.
Mutating an object in the old list is like editing the before photograph too. Now both photographs show the edited value, so there is no visible difference to find.
Create a new object and a new list instead. That preserves the original photograph and makes the change detectable.
Syntax and Examples
Use a stable identifier for item identity, and use immutable models for row data.
data class Artist(
val artistId: Long,
val artistName: String,
val artistCover: String,
val artistAlbums: Int,
val artistTracks: Int
)
class ArtistsDiff : DiffUtil.ItemCallback<Artist>() {
override fun areItemsTheSame(oldItem: Artist, newItem: Artist): Boolean {
return oldItem.artistId == newItem.artistId
}
override fun areContentsTheSame(oldItem: Artist, newItem: Artist): Boolean {
return oldItem == newItem
}
}
When one artist changes, create a replacement with copy() and submit a new list:
val updatedArtists = currentArtists.map { artist ->
if (artist.artistId == changedArtistId) {
artist.copy(artistTracks = artist.artistTracks + 1)
} else {
artist
}
}
artistAdapter.submitList(updatedArtists)
map() creates a new List. copy() creates a new Artist for the changed row. Since the ID is unchanged but artistTracks differs, DiffUtil requests a rebind only for that item.
If you build a list manually, use toList() to create a new list container—but remember that toList() alone is not sufficient if its elements were mutated. Changed elements need new instances too.
artistAdapter.submitList(updatedArtists.toList())
Step by Step Execution
Consider this initial list:
val firstList = listOf(
Artist(1, "Mina", "mina.jpg", 2, 10),
Artist(2, "Theo", "theo.jpg", 4, 22)
)
artistAdapter.submitList(firstList)
Later, Mina receives one more track:
val secondList = firstList.map { artist ->
if (artist.artistId == 1L) {
artist.copy(artistTracks = 11)
} else {
artist
}
}
artistAdapter.submitList(secondList)
The diff runs conceptually as follows:
- It matches
Artist(1, ...)in both lists becauseartistIdis1. - It compares the two
Artistvalues. - The old item has
artistTracks = 10; the new item hasartistTracks = 11. areContentsTheSame()returnsfalse.ListAdaptermarks that row as changed.- RecyclerView calls
onBindViewHolder()for Mina's visible row, and the text becomes2 Albums • 11 Tracks.
Avoid this pattern:
// Broken when this object is already in a submitted list.
val artist = firstList[0]
artist.artistTracks = 11
artistAdapter.submitList(firstList)
If Artist contains mutable var properties, the old snapshot was modified before the comparison. The diff may see 11 on both sides.
Real World Use Cases
Immutable list updates are useful whenever a screen shows a changing collection:
- Music library: update album or track counts after syncing media.
- Shopping cart: replace one product row when its quantity changes.
- Messaging app: replace a message when its delivery status changes from sent to read.
- Task manager: update one task's completion state without redrawing every task.
- API-driven dashboard: submit a fresh list after a network response changes account balances or statuses.
- Room database + LiveData: expose new entity values from queries and let
ListAdaptercalculate efficient UI updates.
Real Codebase Usage
In production Android code, state is commonly treated as immutable from the ViewModel through to the adapter.
Keep UI state immutable
data class ArtistsUiState(
val artists: List<Artist> = emptyList()
)
When an event arrives, produce replacement state instead of editing the existing state:
fun onTrackAdded(artistId: Long) {
_uiState.update { state ->
state.copy(
artists = state.artists.map { artist ->
if (artist.artistId == artistId) {
artist.copy(artistTracks = artist.artistTracks + 1)
} else {
artist
}
}
)
}
}
The Fragment or Activity then observes state and submits it:
viewModel.uiState.observe(viewLifecycleOwner) { state ->
artistAdapter.submitList(state.artists)
}
Keep binding focused on rendering
A ViewHolder should render all values needed for its row every time it binds. Do not assume a previous value is still correct because RecyclerView reuses views.
fun bind(artist: Artist) = with(itemView) {
artistName.text = artist.artistName
artistDetails.text = "${artist.artistAlbums} Albums • ${artist.artistTracks} Tracks"
artistCoverImage.loadURL(artist.artistCover)
}
Use payloads only when needed
For ordinary rows, default diffing and bind() are enough. For expensive rows, getChangePayload() can describe exactly what changed, allowing a partial bind. This is an optimization, not a replacement for immutable updates.
Common Mistakes
Mutating a submitted item
// Problematic if artist is part of the list currently displayed by ListAdapter.
artist.artistTracks += 1
artistAdapter.submitList(artists)
Avoid it: make Artist immutable with val properties and use copy().
val updated = artists.map {
if (it.artistId == artistId) it.copy(artistTracks = it.artistTracks + 1) else it
}
artistAdapter.submitList(updated)
Reusing the same mutable list
val artists = mutableListOf<Artist>()
artists[0] = artists[0].copy(artistTracks = 11)
artistAdapter.submitList(artists)
The list reference is reused. Even when this seems to work, it makes state ownership unclear and can lead to hard-to-reproduce UI issues.
Avoid it: submit a new list instance.
artistAdapter.submitList(artists.toList())
Comparing only the ID in areContentsTheSame()
override fun areContentsTheSame(oldItem: Artist, newItem: Artist): Boolean {
return oldItem.artistId == newItem.artistId
}
This says an item with the same ID never changed.
Avoid it: compare all UI-relevant content. For an immutable data class, oldItem == newItem is often correct.
Leaving displayed properties out of equality
If Artist is not a data class, oldItem == newItem may use reference equality. Alternatively, a custom equality implementation may omit a displayed property.
Avoid it: make the model a data class, or compare the rendered fields explicitly.
override fun areContentsTheSame(oldItem: Artist, newItem: Artist): Boolean {
return oldItem.artistName == newItem.artistName &&
oldItem.artistCover == newItem.artistCover &&
oldItem.artistAlbums == newItem.artistAlbums &&
oldItem.artistTracks == newItem.artistTracks
}
Using notifyDataSetChanged() to hide a state bug
It forces visible rows to redraw, but it skips granular diff updates and can remove useful change animations.
Avoid it: fix the data flow first. Use adapter notification methods only when you manage updates manually rather than through ListAdapter.
Comparisons
| Approach | What it does | Best use |
|---|---|---|
submitList(newList) | Calculates insertions, removals, moves, and changed rows in the background. | Standard approach with ListAdapter. |
notifyItemChanged(position) | Forces one known position to rebind. | Manual adapters when you know exactly which position changed. |
notifyDataSetChanged() | Forces the whole adapter to refresh and skips detailed diff information. | Rare fallback; not normal ListAdapter usage. |
| Mutating an existing object | Changes the old snapshot as well as the apparent new state. | Avoid for adapter state. |
copy() plus map() | Creates a replacement item and a replacement list. | Preferred immutable Kotlin update pattern. |
areItemsTheSame() and areContentsTheSame() also have distinct jobs:
| Callback | Typical comparison | Meaning |
|---|---|---|
areItemsTheSame | oldItem.artistId == newItem.artistId | These represent the same artist. |
areContentsTheSame | oldItem == newItem for a data class | The row's renderable data is unchanged. |
Cheat Sheet
class ArtistsDiff : DiffUtil.ItemCallback<Artist>() {
override fun areItemsTheSame(oldItem: Artist, newItem: Artist) =
oldItem.artistId == newItem.artistId
override fun areContentsTheSame(oldItem: Artist, newItem: Artist) =
oldItem == newItem
}
// Preferred: new item for changed data, new list for submission.
val updatedList = artists.map { artist ->
if (artist.artistId == id) artist.copy(artistTracks = newTrackCount) else artist
}
adapter.submitList(updatedList)
- Use a stable, unique ID in
areItemsTheSame(). - Use immutable
data classmodels where possible. - Do not mutate objects already submitted to
ListAdapter. - Do not modify a list while it may be used by the adapter or diff calculation.
- Ensure
areContentsTheSame()includes every property that affects the row UI. notifyDataSetChanged()is not normally needed aftersubmitList().- Rows bind again when
DiffUtilreports a content change or when RecyclerView needs to reuse a view.
FAQ
Why does my RecyclerView update only after I scroll?
Scrolling causes RecyclerView to bind recycled or newly visible views. That can reveal mutated data even though DiffUtil did not dispatch a change for the currently visible row.
Does submitList() require a new list?
Yes. Treat each submitted list as an immutable snapshot. Submit a new list instance for new state.
Is artists.toList() enough to fix ListAdapter updates?
It creates a new list container, but it does not copy the objects inside it. If an existing Artist was mutated, create a new Artist with copy() too.
Why is a Kotlin data class helpful with DiffUtil?
A data class generates value-based equals(), so oldItem == newItem compares constructor properties rather than just object references.
Should areContentsTheSame() compare every property?
Compare every property that affects how the row is rendered. Properties not used by the row do not need to trigger a rebind for that row.
Can I call notifyItemChanged() after submitList()?
Do not mix manual notification calls with ListAdapter's diff results for the same update. Prefer fixing the submitted data snapshots.
Why should I use viewLifecycleOwner when observing in a Fragment?
viewLifecycleOwner stops observation when the Fragment view is destroyed, preventing UI updates from being delivered to an invalid view lifecycle.
Mini Project
Description
Build a small artist-library update flow. The screen starts with two artists, then simulates receiving a new track for one artist. The project demonstrates how a new immutable item and a new list allow ListAdapter to update only the changed row.
Goal
Update one artist's track count through submitList() without calling notifyDataSetChanged().
Requirements
Create an immutable Artist data class with an ID, name, album count, and track count.
Create a ListAdapter using a DiffUtil.ItemCallback.
Render the artist name and statistics in each RecyclerView row.
Provide a function that increments the track count for an artist by ID.
Submit a new list and a copied changed artist when the count changes.
Keep learning
Related questions
Accessing Kotlin Extension Functions from Java
Learn how Kotlin extension functions are compiled and how to call them correctly from Java with clear examples and common pitfalls.
Add a Maven Repository URL in Gradle Kotlin DSL (build.gradle.kts)
Learn how to add Maven Central and a custom Maven repository URL in Gradle Kotlin DSL using build.gradle.kts syntax.
Allow HTTP and HTTPS in Android 9 Pie with Network Security Configuration
Learn how Android 9 Pie handles cleartext HTTP traffic and how to allow HTTP and HTTPS safely using network security config.