Search code examples
androidkotlinandroid-jetpack-compose

Why doen't Compose trigger a recomposition on State update?


I'm developing an Android app using Jetpack compose and the MVVM architecture. When I click the "Add" button, a new note is added to the list, but the UI does not recompose to show the new note.

Here is my complete code:

data class Note(
    val text: String = ""
)


data class UiState(
    val notes: List<Note>
)

class NoteRepository {
    private val notes = mutableListOf<Note>()

    fun getNotes(): List<Note> = notes

    fun addNote(note: Note) {
        notes.add(note)
    }
}


class NoteViewModel : ViewModel() {
    private val repository = NoteRepository()

    private val _uiState = MutableStateFlow(UiState(repository.getNotes()))

    val uiState: StateFlow<UiState> = _uiState.asStateFlow()

    fun addNote(note: Note) {
        repository.addNote(note)

        // Logging to check the state update
        Log.d("NoteViewModel", "Notes list: ${repository.getNotes()}")

        //_uiState.value = uiState.value.copy(notes = repository.getNotes())
        _uiState.value = UiState(repository.getNotes())
    }
}


@Composable
fun Board(modifier: Modifier = Modifier, viewModel: NoteViewModel) {
    val uiState by viewModel.uiState.collectAsState()

    Box(modifier = Modifier
        .fillMaxSize()
        .then(modifier)
    ) {
        Button(
            onClick = { viewModel.addNote(Note()) },
            modifier = Modifier.align(Alignment.BottomEnd)
        ) {
            Text(text = "Add")
        }

        Column {
            uiState.notes.forEach { _ ->
                Box(
                    modifier = Modifier
                        .padding(6.dp)
                        .size(20.dp)
                        .background(Color.Blue)
                )
            }
        }
    }
}

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            NoteTheme {
                Board(viewModel = viewModel())
            }
        }
    }
}

I can confirm that the note is added to the list when clicking on the button. However, the UI does not update to show the newly added note.

Edit

Using mutableStateListOf instead of mutableListOf in the repository class solves the issue:

private val notes = mutableStateListOf<Note>()

However, this approach makes the repository depend on Compose, which I would prefer to avoid.


Solution

  • You are right not to use MutableState in the repository, but it shouldn't even be used in the view model. You should use Flows instead.

    You already employ Flows in the view model, but they should also be used in the lower levels of your app. A lot of data source libraries (databases, network IO, filesystem IO) already natively support flows, and others (like callback-based APIs) can be easily converted to flows. The general idea is to create a flow as soon as possible and pass it up through your layers into the UI where it is collected. On the way through the layers the content can be transformed as needed.

    In your case the repository is the data source so the data should be exposed as a flow there:

    class NoteRepository {
        private val _notes = MutableStateFlow<List<Note>>(emptyList())
        val notes: StateFlow<List<Note>> = _notes.asStateFlow()
    
        fun addNote(note: Note) {
            _notes.update { it + note }
        }
    }
    

    The view model then transforms the content (a List<Note>) to a UiState by applying mapLatest. The resulting flow is converted to a StateFlow so it can be easily collected by the UI:

    class NoteViewModel : ViewModel() {
        private val repository = NoteRepository()
    
        @OptIn(ExperimentalCoroutinesApi::class)
        val uiState: StateFlow<UiState> = repository.notes
            .mapLatest { UiState(it) }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = UiState(emptyList()),
            )
    
        fun addNote(note: Note) {
            repository.addNote(note)
        }
    }
    

    There are other transformation functions for flows. You can, for example, combine multiple flows or replace one flow with another, depending on its content (flatMapLatest).

    In your composable you should use collectAsStateWithLifecycle instead of collectAsState to collect the flow. That will take care of properly starting and stopping the flow when the composable enters and leaves the composition to save resources. You need the gradle dependency androidx.lifecycle:lifecycle-runtime-compose for that. In your specific case with an underlying StateFlow in the repository that won't be relevant, but you might one day switch out the repository by a database, and then this would be relevant. Better to use collectAsStateWithLifecycle from the start.