Question

Fix ListAdapter Updates in RecyclerView with Kotlin DiffUtil

kotlinandroidlistadapter

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:

  1. 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.
  2. 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.

Take Quiz

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:

  1. It matches Artist(1, ...) in both lists because artistId is 1.
  2. It compares the two Artist values.
  3. The old item has artistTracks = 10; the new item has artistTracks = 11.
  4. areContentsTheSame() returns false.
  5. ListAdapter marks that row as changed.
  6. RecyclerView calls onBindViewHolder() for Mina's visible row, and the text becomes 2 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 ListAdapter calculate efficient UI updates.
Take Quiz

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

ApproachWhat it doesBest 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 objectChanges 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:

CallbackTypical comparisonMeaning
areItemsTheSameoldItem.artistId == newItem.artistIdThese represent the same artist.
areContentsTheSameoldItem == newItem for a data classThe row's renderable data is unchanged.
Take Quiz

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 class models 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 after submitList().
  • Rows bind again when DiffUtil reports 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.

Take Quiz

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.

Take Quiz

Keep learning

Related questions

View more