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) }
}
}
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)
}
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)
}
}
}
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")
}
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")
}
}
}
}
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
}
}
Solution:
@Composable
fun Timer(onTimeout: () -> Unit) {
val currentOnTimeout by rememberUpdatedState(onTimeout)
LaunchedEffect(Unit) {
delay(5000)
currentOnTimeout() // ✅ Always latest callback
}
}
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}")
}
}
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
)
}
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
)
)
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)
)
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
)
}
Using Material Theme Builder
Google's Material Theme Builder generates color schemes from your brand color.
- Visit the builder and pick your brand color
- Download the generated code
- Integrate the color definitions into your theme
Best Practices
- LaunchedEffect for one-time setup: API calls, initialization on screen load
- DisposableEffect for resources: Subscriptions, listeners, database connections
- Avoid SideEffect unless necessary: It runs frequently; prefer LaunchedEffect or DisposableEffect
- Use rememberCoroutineScope for user events: Button clicks, form submission
- Theme consistency: Define all colors, typography, shapes in a central theme file
- Test themes: Ensure your custom theme works in both light and dark modes
- 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)