Does not recompose when global MutableState is not in MainActivity.kt

I am working on an Android app with Jetpack Compose. Here I need to load data (a list of strings) from the app’s storage to display in a LazyColumn, and allow this list to be updated based on user interaction. Essentially, when the user clicks on an item in this list, that item is moved to the top.

To do this I have a MutableState<Result<SnapshotStateList<String>>?> in a global variable (let’s call it recents). This stores the state of the result of loading the data, where null means that it has not been loaded yet. I also use SnapshotStateList so that I can update it later with a different order of items.

(btw, I’m using a custom Result class, but that’s not relevant)

The function that loads the data to recents executes the loading in a different thread, and sets the MutableState’s value when it is done.

private val recents: MutableState<Result<SnapshotStateList<String>>?> = mutableStateOf(null)

@Composable
fun getRecentlySelectedCurrencies(): State<Result<List<String>>?> {
    if (recents.value == null) {
        val context = LocalContext.current

        rememberWork(recents) {
            val file = File(context.filesDir, RECENT_CURRENCIES_FILE_NAME)
            file.createNewFile()
            file.readText()
                .split('\n')
                .dropLast(1)
                .toMutableStateList()
        }
    }

    @Suppress("UNCHECKED_CAST")
    return recents as State<Result<List<String>>?>
}

@Composable
fun <T> rememberWork(
    state: MutableState<Result<T>?> = mutableStateOf(null),
    executor: Executor = WORKER_THREAD, // Single-threaded Executor Service
    task: suspend () -> T
): MutableState<Result<T>?> = remember {
    suspend fun runTask()
        // Don't allow an exception to terminate the worker thread; gotta catch em all.
        = try {
            Result.Ok(task())
        } catch (e: Throwable) {
            Result.Err(e)
        }

    if (state.value != null) {
        throw IllegalArgumentException("Value of 'state' must start as null")
    }

    executor.execute {
        setWorkerThreadId(executor)

        runBlocking {
            state.value = runTask()
        }
    }

    state
}

Here is the composable with the UI elements. When either the MutableState or SnapshotStateList are updated, this should be recomposed.

@Composable
fun CurrencySelectorButton(...) {
    val recentCurrencies by getRecentlySelectedCurrencies()
    val orderedCurrencies = remember {
        val currencies = Currency.getAvailableCurrencies()
        ...
    }

    LazyColumn(
        ...
    ) {
        when (recentCurrencies) {
            // Don't show any extra items, but re-sort the currencies list.
            is Result.Ok -> {
                // Move around elements in orderedCurrencies.
                ...
            }
            // Recent currencies not loaded yet.
            // Show loading item at the top.
            null -> this.item {
                this.LoadingMenuItem()
            }
            // Show an error item at the top.
            is Result.Err -> this.item {
                val err = (recentCurrencies as Result.Err).error
                this.ErrorMenuItem(err)
            }
        }
        
        this.items(
            items = orderedCurrencies.currencySearchFilter(currencySearchState.text),
            key = { currency -> currency.currencyCode }
        ) { currency ->
            ...
        }
    }
}

The problem is that I want to move recents and the function to a different file. But if recents is not in MainActivity.kt, the UI won’t be recomposed when the MutableState is updated and it will think that recentCurrencies is still null. Recomposition only happens when the SnapshotStateList is updated when the user selects an item and it is moved to the front of the list.

This is not a problem with a data race because there is only one line (in rememberWork()) that can set the value of recents, and the value is only set once in the entire runtime of the program. And even if it was, why would putting recents in MainActivity.kt prevent the data race?

I checked if maybe rememberWork() was not setting the value of the MutableState when the task returns, but it definitely is doing that (checked with a print statement).

Also, I tried adding a delay to the file loading task in case this has something to do with thread , and it seems that calling delay(50) with at least 50 milliseconds fixes it. So maybe I was wrong about it not being a data race?

If using a MutableState is not the best way to execute a task in a different thread and get a return value, then what is the right way of doing it?

Just clarifying, are you saying that moving the global MutableState from MainActivity.kt to a different file changes the behaviour of your code? If so, that’s very strange
The “right way” to execute some task and recompose when the result is available would be to use a LaunchedEffect to run a suspend function, and have it modify a MutableState that you hold locally (instead of having a global, which is a big no no)

Just clarifying, are you saying that moving the global MutableState from MainActivity.kt to a different file changes the behaviour of your code? If so, that’s very strange

Exactly

use a LaunchedEffect to run a suspend function, and have it modify a MutableState that you hold locally

I need the work to be executed in a different worker thread (not for this exact use case, but I also don’t want to have this same problem elsewhere). Also, I can’t have the MutableState exposed to the UI composable because modifying the recents variable should have side effects (i.e. writing the data to the file), so it should only be modified through a specific function.

(Let me preface by saying I’m not very experienced with Compose)
I’d modify your rememberWork to do something like:


@Composable
fun <T> rememberWork(
    executor: Executor = WORKER_THREAD, // Single-threaded Executor Service
    task: suspend () -> T
): Result<T>? {
    var state by remember { mutableStateOf<Result<T>>(null) }
    suspend fun runTask()
        // Don't allow an exception to terminate the worker thread; gotta catch em all.
        = try {
            Result.Ok(task())
        } catch (e: Throwable) {
            Result.Err(e)
        }

    LaunchedEffect(Unit) {
        setWorkerThreadId(executor)

        withContext(executor.asCoroutineDispatcher()) {
            state = runTask()
        }
    }

    state
}

You really, really, should pass some key parameters to rememberWork though, so that it’s relaunched whenever the relevant parameters change.
I’m somewhat suspicious of your SnapshotStateList usage, though. How is the list being updated? Maybe the delay is somehow resulting in the file being read at a later time when it’s absolutely updated or something, idk.

I’ll try using LaunchedEffect, but without passing a key in, because I don’t want the task to be re-ran when some state changes. If I read a file to load data into memory, re-doing the IO operation would unnecessary.

I don’t think this problem has anything to do with the SnapshotStateList because that is only updated on user interaction. When the user clicks on an item in the LazyColumn, it calls a function that finds a currency code (e.g. "USD") in recents and moves the element to index 0.

When the app is opened, I have to click a Floating Action Button to bring up the screen that the CurrencySelectorButton Composable is rendered in. The only action that happens here is that getRecentlySelectedCurrencies() gets called and reads the file. No other reads or writes to that file happen here.

I don’t know if by that you mean that the MutableState value is not being updated, but it is because I put a print statement directly after the line state = runTask(), and I see the log where it’s expected to be.

Ok so I just went and tried it, and it worked. I made a singleton instead of global variables and functions, and for rememberWork() I left the key as Unit so it never gets re-run.

object RecentCurrencies {
    private lateinit var state: MutableState<Result<SnapshotStateList<String>>?>

    @Composable
    fun get(): State<Result<List<String>>?> {
        val context = LocalContext.current
        this.state = rememberWork {
            val file = File(context.filesDir, RECENT_CURRENCIES_FILE_NAME)
            file.createNewFile()
            file.readText()
                .split('\n')
                .dropLast(1)
                .toMutableStateList()
        }

        return this.state
    }
}

// This is rememberWork verbatim
@Composable
fun <T> rememberWork(
    executor: Executor = WORKER_THREAD,
    task: suspend () -> T
): MutableState<Result<T>?> {
    val state = remember { mutableStateOf<Result<T>?>(null) }
    suspend fun runTask()
        // Don't allow an exception to terminate the worker thread; gotta catch em all.
        = try {
            Result.Ok(task())
        } catch (e: Throwable) {
            Result.Err(e)
        }

    LaunchedEffect(Unit) {
        withContext(executor.asCoroutineDispatcher()) {
            setWorkerThreadId(executor)

            state.value = runTask()
        }
    }

    return state
}

The composable actually gets recomposed this time.
Thank you so much for your help @kyay10

1 Like