DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Compose Side Effects & Custom Themes — Advanced Android UI Patterns

Compose Side Effects & Custom Themes — Advanced Android UI Patterns

Jetpack Compose's reactive model means UI automatically updates when state changes. But sometimes you need to trigger actions outside the composable—logging, analytics, API calls, or managing external resources. That's where side effects come in.

This guide covers the complete side effect toolkit and modern Compose theme design patterns.

Understanding Compose Side Effects

Side effects in Compose are operations that happen as a side effect of composition or recomposition. They're not direct part of the UI rendering logic.

1. LaunchedEffect — One-Time Setup & Key-Based Restart

LaunchedEffect launches a coroutine when the composable enters the composition, and cancels it when the composable leaves.

Use case: Fetch data on screen load, start a timer, or listen to events.

@Composable
fun UserProfileScreen(userId: String) {
    var user by remember { mutableStateOf<User?>(null) }
    var isLoading by remember { mutableStateOf(true) }

    LaunchedEffect(userId) {
        isLoading = true
        user = fetchUser(userId)
        isLoading = false
    }

    if (isLoading) {
        CircularProgressIndicator()
    } else {
        user?.let { Text(it.name) }
    }
}
Enter fullscreen mode Exit fullscreen mode

Key behavior: The lambda re-runs whenever userId changes. If you pass no keys (or use Unit), it runs once.

// Run once on first composition
LaunchedEffect(Unit) {
    setupAnalytics()
}

// Re-run whenever userId OR category changes
LaunchedEffect(userId, category) {
    loadContent(userId, category)
}
Enter fullscreen mode Exit fullscreen mode

2. DisposableEffect — Setup + Cleanup

DisposableEffect is like LaunchedEffect but with guaranteed cleanup. Perfect for managing resources.

Use case: Register listeners, subscribe to flows, manage database connections.

@Composable
fun ChatScreen() {
    DisposableEffect(Unit) {
        val messageListener = listenForNewMessages { message ->
            addMessageToUI(message)
        }

        onDispose {
            messageListener.unsubscribe()
        }
    }

    LazyColumn {
        items(messages) { msg ->
            MessageRow(msg)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The onDispose block runs when:

  • The composable leaves composition
  • The effect keys change (and the effect re-runs)

3. SideEffect — Every Recomposition

SideEffect runs after every successful recomposition. Use sparingly—it's called frequently.

Use case: Analytics tracking, logging render counts, syncing UI state to external systems.

@Composable
fun ExpensiveScreen(value: Int) {
    SideEffect {
        println("Rendered with value=$value")
        analytics.logScreenView("ExpensiveScreen", value)
    }

    Text("Value: $value")
}
Enter fullscreen mode Exit fullscreen mode

Warning: This runs every time the composable recomposes, potentially many times per second. Avoid heavy operations.

4. rememberCoroutineScope — Event Handlers

For executing coroutines in response to user events (clicks, form submission), use rememberCoroutineScope.

Use case: Handle button clicks, form submission, animations.

@Composable
fun LoginForm() {
    var email by remember { mutableStateOf("") }
    var password by remember { mutableStateOf("") }
    var isLoading by remember { mutableStateOf(false) }

    val scope = rememberCoroutineScope()

    Column {
        TextField(
            value = email,
            onValueChange = { email = it },
            label = { Text("Email") }
        )
        TextField(
            value = password,
            onValueChange = { password = it },
            label = { Text("Password") },
            visualTransformation = PasswordVisualTransformation()
        )
        Button(
            onClick = {
                scope.launch {
                    isLoading = true
                    try {
                        val result = loginUser(email, password)
                        navigateToHome(result)
                    } catch (e: Exception) {
                        showError(e.message)
                    } finally {
                        isLoading = false
                    }
                }
            }
        ) {
            if (isLoading) {
                CircularProgressIndicator(Modifier.size(20.dp))
            } else {
                Text("Login")
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

5. rememberUpdatedState — Closure Problems

When a callback captures state at composition time, it becomes stale. rememberUpdatedState solves this.

Problem:

@Composable
fun Timer(onTimeout: () -> Unit) {
    LaunchedEffect(Unit) {
        delay(5000)
        onTimeout() // ❌ Stale: captures old onTimeout reference
    }
}
Enter fullscreen mode Exit fullscreen mode

Solution:

@Composable
fun Timer(onTimeout: () -> Unit) {
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    LaunchedEffect(Unit) {
        delay(5000)
        currentOnTimeout() // ✅ Always latest callback
    }
}
Enter fullscreen mode Exit fullscreen mode

6. produceState — Derive State from Non-Compose Sources

Convert flows, callbacks, or other async sources into Compose state.

Use case: Wrapping Flow/LiveData, converting async operations into reactive state.

@Composable
fun LocationTracker() {
    val location by produceState<Location?>(initialValue = null) {
        val callback = locationManager.startTracking { newLocation ->
            value = newLocation
        }

        awaitDispose {
            locationManager.stopTracking(callback)
        }
    }

    location?.let {
        Text("Lat: ${it.latitude}, Lon: ${it.longitude}")
    }
}
Enter fullscreen mode Exit fullscreen mode

Custom Theme Design in Compose

Material 3 provides comprehensive theming via lightColorScheme(), darkColorScheme(), and MaterialTheme().

Basic Light & Dark Themes

private val LightColors = lightColorScheme(
    primary = Color(0xFF6750A4),
    onPrimary = Color.White,
    primaryContainer = Color(0xFFEADDFF),
    onPrimaryContainer = Color(0xFF21005E),
    secondary = Color(0xFF625B71),
    tertiary = Color(0xFF7D5260),
    background = Color(0xFFFFFBFE),
    surface = Color(0xFFFFFBFE),
    error = Color(0xFFB3261E),
)

private val DarkColors = darkColorScheme(
    primary = Color(0xFFD0BCFF),
    onPrimary = Color(0xFF371E55),
    primaryContainer = Color(0xFF4F378B),
    onPrimaryContainer = Color(0xFFEADDFF),
    secondary = Color(0xFFCCC7F0),
    tertiary = Color(0xFFF2B8D4),
    background = Color(0xFF1C1B1F),
    surface = Color(0xFF1C1B1F),
    error = Color(0xFFF2B8B5),
)

@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colorScheme = if (darkTheme) DarkColors else LightColors

    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography(),
        shapes = Shapes(),
        content = content
    )
}
Enter fullscreen mode Exit fullscreen mode

Custom Typography

val Typography = Typography(
    displayLarge = TextStyle(
        fontSize = 57.sp,
        fontWeight = FontWeight.W400,
        lineHeight = 64.sp,
        letterSpacing = 0.sp
    ),
    headlineSmall = TextStyle(
        fontSize = 24.sp,
        fontWeight = FontWeight.W500,
        lineHeight = 32.sp,
        letterSpacing = 0.sp
    ),
    bodyMedium = TextStyle(
        fontSize = 14.sp,
        fontWeight = FontWeight.W400,
        lineHeight = 20.sp,
        letterSpacing = 0.25.sp
    ),
    labelSmall = TextStyle(
        fontSize = 11.sp,
        fontWeight = FontWeight.Medium,
        lineHeight = 16.sp,
        letterSpacing = 0.5.sp
    )
)
Enter fullscreen mode Exit fullscreen mode

Custom Shapes

val Shapes = Shapes(
    extraSmall = RoundedCornerShape(4.dp),
    small = RoundedCornerShape(8.dp),
    medium = RoundedCornerShape(12.dp),
    large = RoundedCornerShape(16.dp),
    extraLarge = RoundedCornerShape(28.dp)
)
Enter fullscreen mode Exit fullscreen mode

Extending Theme with Custom Colors

Material 3's default palette may not include all your brand colors. Use CompositionLocal to extend the theme:

data class ExtendedColors(
    val brandColor: Color,
    val brandColorContainer: Color,
    val onBrandColor: Color,
)

val LocalExtendedColors = compositionLocalOf {
    ExtendedColors(
        brandColor = Color.Unspecified,
        brandColorContainer = Color.Unspecified,
        onBrandColor = Color.Unspecified,
    )
}

@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colorScheme = if (darkTheme) DarkColors else LightColors
    val extendedColors = if (darkTheme) {
        ExtendedColors(
            brandColor = Color(0xFFFFB81C),
            brandColorContainer = Color(0xFF4A3A00),
            onBrandColor = Color(0xFF2A2400)
        )
    } else {
        ExtendedColors(
            brandColor = Color(0xFFFFB81C),
            brandColorContainer = Color(0xFFFFECC4),
            onBrandColor = Color(0xFF4A3A00)
        )
    }

    CompositionLocalProvider(
        LocalExtendedColors provides extendedColors
    ) {
        MaterialTheme(
            colorScheme = colorScheme,
            typography = Typography,
            shapes = Shapes,
            content = content
        )
    }
}

// Usage in composables
@Composable
fun BrandButton(
    onClick: () -> Unit,
    content: @Composable RowScope.() -> Unit
) {
    Button(
        onClick = onClick,
        colors = ButtonDefaults.buttonColors(
            containerColor = LocalExtendedColors.current.brandColor,
            contentColor = LocalExtendedColors.current.onBrandColor
        ),
        content = content
    )
}
Enter fullscreen mode Exit fullscreen mode

Using Material Theme Builder

Google's Material Theme Builder generates color schemes from your brand color.

  1. Visit the builder and pick your brand color
  2. Download the generated code
  3. Integrate the color definitions into your theme

Best Practices

  1. LaunchedEffect for one-time setup: API calls, initialization on screen load
  2. DisposableEffect for resources: Subscriptions, listeners, database connections
  3. Avoid SideEffect unless necessary: It runs frequently; prefer LaunchedEffect or DisposableEffect
  4. Use rememberCoroutineScope for user events: Button clicks, form submission
  5. Theme consistency: Define all colors, typography, shapes in a central theme file
  6. Test themes: Ensure your custom theme works in both light and dark modes
  7. Accessibility: Ensure color contrast meets WCAG standards

Summary

  • LaunchedEffect: Coroutines on composition/key change
  • DisposableEffect: Setup + cleanup for resources
  • SideEffect: Run after every recomposition (use rarely)
  • rememberCoroutineScope: Event-driven coroutines
  • rememberUpdatedState: Keep callbacks fresh
  • produceState: Convert async sources to state
  • Custom themes: lightColorScheme/darkColorScheme + CompositionLocal for extensions

Master these patterns and your Compose apps become predictable, responsive, and maintainable.


Master Android Development: 8 Android App Templates → https://myougatheax.gumroad.com

Top comments (0)