When remember() Does Not Remember, Consider if()

One of my concerns when Jetpack Compose was released is its reliance on magic coming from things like the Compose Compiler. Magic is wonderful for newcomers, as it reduces cognitive load. Magic is fine for serious experts (magicians), as for them it is not magic, but rather is sufficiently advanced technology. Magic can be a problem for those of us in between those extremes, to the extent it makes it difficult for us to understand subtle behavior differences coming from small code changes.

For example, I have been using Alex Styl’s ComposeTheme recently, to help organize a non-Material design system in Compose UI. The way that you build a theme with ComposeTheme is via a buildComposeTheme() top-level function:

val MyTheme = buildComposeTheme { 
  // TODO wonderful theme bits go here
}

This returns a composable function, which you can apply akin to MaterialTheme():

@Composable
fun MainScreen() {
    MyTheme {
        BasicText("Um, hi!")
    }
}

This works well.

I then added support for light and dark themes. Alex’s documentation shows doing that outside of the constructed theme function:


@Composable
fun MainScreen() {
    val MyTheme = if (isSystemInDarkTheme()) MyDarkTheme else MyLightTheme

    MyTheme {
        // use the theme, where color references get mapped to light or dark
    }
}

Here, MyDarkTheme() and MyLightTheme() are created using buildComposeTheme(), just with different colors. We choose which one to use, then apply it to our content.

I wanted to hide the decision-making, so I didn’t need it sprinkled throughout the code (e.g., @Preview functions). So, I wrote my own wrapper:

@Composable
fun MyTheme(content: @Composable () -> Unit) {
    if (isSystemInDarkTheme()) MyDarkTheme(content) else MyLightTheme(content)
}

This could be called like MyTheme() was before, routing to MyDarkTheme() or MyLightTheme() as needed.

And it worked… or so I thought.

The app opts out of all automatic configuration change “destroy the activity” behavior via android:configChanges. What happens is that Compose UI recomposes, and we update the UI based on the new Configuration, not significantly different than updating the UI based on the result of some other sort of data change.

What I noticed was that while the app worked, if I changed the theme while the app was running, everything would reset to the beginning. So, if I did some stuff in the app (e.g., navigated in bottom nav), then used the notification shade tile to turn on/off dark mode, the app would draw the right theme, but my changes would be undone (e.g., I would be back at the default bottom nav location).

Eventually, after some debugging, I discovered that remember() seemed to stop working. 😮

@Composable
fun MainScreen() {
    MyTheme {
        val uuid = remember { UUID.randomUuid() }

        BasicText("Um, hi! My name is: $uuid")
    }
}

Here, I remember a generated UUID. That should survive recomposition. For most things, it would – I could rotate the screen without issue. But if I changed theme, I would get a fresh UUID.

🧐

Much debugging later, I realized the problem.

Let’s go back to the MyTheme() implementation:

@Composable
fun MyTheme(content: @Composable () -> Unit) {
    if (isSystemInDarkTheme()) MyDarkTheme(content) else MyLightTheme(content)
}

When I toggle dark mode, my use of isSystemInDarkTheme() triggers a recomposition. Let’s suppose that isSystemInDarkTheme() originally returned false, then later returns true on the recomposition. The false meant that my original composition of MyTheme() went down the MyLightTheme() branch. The later recomposition takes me down the MyDarkTheme() branch. Compose treats those as separate compositions. MyTheme() is recomposing, but it is doing so by discarding the MyLightTheme() composition and creating a new MyDarkTheme() composition. It does not matter whether content would generate the same composition nodes or not — the change in the root from MyLightTheme() to MyDarkTheme() causes the swap in compositions.

My uuid is in the content lambda expression. When we dispose of the MyLightTheme() composition and switch to the MyDarkTheme() composition, we start over with respect to the remember() call, and I wind up with a fresh random UUID.

One workaround is to “lift the if”, blending Alex’s original approach with mine:

@Composable
fun MyTheme(content: @Composable () -> Unit) {
    val theme = if (isSystemInDarkTheme()) MyDarkTheme else MyLightTheme

    theme(content)
}

This does the same thing, but Compose treats this as a single changed composition, and the remember() is retained. To be honest, I am not completely clear why this workaround works. This is still magic to me, though I am certain that there are others for whom the reasoning is clear.

This is the sort of thing that we have to watch out for when working in Compose. Compose is a principled framework, but the Principle of Least Surprise is not always followed… at least for those among us who are not magicians.