Writing Composable Functions
Anatomy of a Composable
A composable function is any Kotlin function annotated with @Composable. It describes a piece of UI. Composable functions are hierarchical: small composables are composed into larger ones, forming a tree.
@Composable
fun MessageCard(message: Message) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Avatar(message.sender)
Column {
Text(
text = message.sender,
style = MaterialTheme.typography.titleMedium
)
Text(
text = message.body,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
Composition Is a Tree
Every composable call adds a node to the composition tree. The nesting in your code directly maps to the UI hierarchy:
MessageCard
Row
Avatar
Column
Text (sender)
Text (body)
This tree structure is how Compose knows what to update and what to skip.
Naming Conventions
Compose follows consistent naming conventions:
- Composable functions use PascalCase nouns:
ProfileCard,NavigationBar,SearchBar - Modifier parameters are always named
modifierand placed first:
@Composable
fun MyComponent(
modifier: Modifier = Modifier,
label: String
) {
Text(
text = label,
modifier = modifier.padding(8.dp)
)
}
- Lambda parameters come at the end (Kotlin trailing lambda convention):
Card(
modifier = Modifier.fillMaxWidth(),
onClick = { /* handle click */ }
) {
Text("Click me")
}
Composables vs Regular Functions
A @Composable function cannot be called from regular Kotlin code, only from other composable functions or from setContent. This is enforced by the compiler.
// This works
@Composable
fun Parent() {
Child() // Child is @Composable
}
// This does NOT compile
fun RegularFunction() {
Child() // Error: @Composable call expected
}
Common Patterns
Single-responsibility composables do one thing well:
@Composable
fun UserAvatar(url: String, size: Dp = 48.dp) {
AsyncImage(
model = url,
contentDescription = null,
modifier = Modifier
.size(size)
.clip(CircleShape)
)
}
Slot-based composables let callers fill specific slots:
@Composable
fun CustomScaffold(
topBar: @Composable () -> Unit,
content: @Composable () -> Unit
) {
Column {
topBar()
content()
}
}
This pattern is used extensively by Material components like Scaffold and AlertDialog.
Recomposition in Detail
When Does Recomposition Happen?
Recomposition is triggered when:
- A
StateorMutableStatevalue that a composable reads changes. - A composable's parent recomposes and passes new parameters.
@Composable
fun Counter() {
var count by remember { mutableIntStateOf(0) }
Button(onClick = { count++ }) {
Text("Count: $count")
}
// Only the Button lambda recomposes when count changes
// Counter itself recomposes because it reads `count`
}
Skipping and Optimization
The Compose compiler analyzes your code and generates skip logic. A composable can be skipped if:
- All parameters are stable (same instance or equal value).
- The function does not read any
Statethat changed.
// This composable can be skipped if `user` is stable and unchanged
@Composable
fun UserRow(user: User) {
Text(user.name)
}
If User is a data class with val properties, it is stable. If it has var properties, it is not.
Common Recomposition Pitfalls
Creating objects inside composable functions:
// BAD: Creates a new list on every recomposition
@Composable
fun BadExample() {
val items = listOf("A", "B", "C") // Reallocated every time
LazyColumn {
items(items) { Text(it) }
}
}
// GOOD: Use remember to preserve across recompositions
@Composable
fun GoodExample() {
val items = remember { listOf("A", "B", "C") }
LazyColumn {
items(items) { Text(it) }
}
}
Expensive calculations without caching:
// BAD: Recalculates every recomposition
@Composable
fun FilteredList(items: List<String>, query: String) {
val filtered = items.filter { it.contains(query) }
LazyColumn {
items(filtered) { Text(it) }
}
}
// GOOD: Cache with derivedStateOf
@Composable
fun FilteredList(items: List<String>, query: String) {
val filtered by remember(items, query) {
derivedStateOf { items.filter { it.contains(query) } }
}
LazyColumn {
items(filtered) { Text(it) }
}
}
Recomposition Scope
Recomposition happens at the composable function level, not at the individual UI node level. If a composable function reads changed state, the entire function body re-executes, but only the composables inside that actually changed will have their UI updated.
Understanding scope is essential for writing performant Compose UI. Keep recomposition scopes as small as possible by extracting state reads into focused composable functions.
CompositionLocals and Scoped Composables
The Problem with Prop Drilling
As your composable tree grows, passing data through many layers becomes tedious:
@Composable
fun App(user: User) {
Screen(user = user)
}
@Composable
fun Screen(user: User) {
Sidebar(user = user)
}
@Composable
fun Sidebar(user: User) {
UserName(name = user.name) // User was passed through 3 layers
}
This is called prop drilling. It works for a few levels, but becomes unmanageable in large apps.
CompositionLocal to the Rescue
CompositionLocal lets you implicitly pass data down the tree without explicit parameters:
val LocalUser = compositionLocalOf<User> { error("No user provided") }
@Composable
fun App(user: User) {
CompositionLocalProvider(LocalUser provides user) {
Screen()
}
}
@Composable
fun Screen() {
Sidebar() // No parameter needed
}
@Composable
fun Sidebar() {
val user = LocalUser.current // Reads the provided value
UserName(name = user.name)
}
Any composable in the subtree can access LocalUser.current without receiving it as a parameter.
When to Use CompositionLocal
Good use cases:
- Theme data (colors, typography)
- Authentication state
- Navigation controller
- Application-level dependencies
Avoid using CompositionLocal for:
- Data that changes frequently and only affects one screen
- Data that belongs in a ViewModel
- Simple prop passing where parameters are clearer
Built-in CompositionLocals
Material provides several:
LocalContext-- currentContextLocalDensity-- screen densityLocalLayoutDirection-- RTL/LTRLocalColors-- current color schemeLocalTypography-- current typography
@Composable
fun DensityExample() {
val density = LocalDensity.current
val pixels = with(density) { 16.dp.toPx() }
Text("16.dp = $pixels pixels")
}
Custom CompositionLocal
You can create your own for app-wide concerns:
val LocalAnalytics = compositionLocalOf<AnalyticsService> {
error("Analytics not provided")
}
// Provide it near the root of your app
CompositionLocalProvider(LocalAnalytics provides analyticsService) {
MyApp()
}
// Use it anywhere deep in the tree
@Composable
fun TrackScreen(screenName: String) {
LaunchedEffect(screenName) {
LocalAnalytics.current.logScreenView(screenName)
}
}
Quiz
1. What is the naming convention for composable functions in Compose?
2. Why should you extract state reads into focused composable functions?
3. What is prop drilling and how does CompositionLocal address it?
4. Which parameter should always come first in a composable function?
Flashcards
Question
What makes a composable function skippable during recomposition?
Click to reveal answer
Answer
All parameters must be stable (immutable types or data classes with stable properties), and the function must not read changed State values.
Question
What is the correct order for composable parameters?
Click to reveal answer
Answer
1. Required params, 2. modifier (first optional), 3. other optional params, 4. trailing lambda for content.
Question
When should you use CompositionLocal vs explicit parameters?
Click to reveal answer
Answer
Use CompositionLocal for app-wide data (theme, auth, analytics) that many composables need. Use explicit parameters for data specific to a single component.
Question
What is a slot-based composable?
Click to reveal answer
Answer
A composable that accepts @Composable lambda parameters for specific UI slots, such as Scaffold accepting topBar and content lambdas.
Revision Notes
Key Takeaways
- 1. Composable functions are Kotlin functions with @Composable annotation that describe UI
- 2. Recomposition only re-executes functions whose inputs have changed, not the entire tree
- 3. Follow naming conventions: PascalCase nouns, modifier first, trailing lambdas for content
- 4. CompositionLocal avoids prop drilling for app-wide data like theme and auth state
- 5. Keep recomposition scopes small by extracting state reads into focused composables
Interview Tips
- • Explain the difference between composition (initial build) and recomposition (updates)
- • Know why stability matters: unstable types prevent Compose from skipping recomposition
- • Be ready to discuss prop drilling and when CompositionLocal is appropriate
- • Discuss slot-based composables and how Material uses this pattern
Cheat Sheet
Composable Functions Cheat Sheet
Anatomy:
@Composableannotation required- Composables are PascalCase nouns (e.g., ProfileCard)
- Modifier is always the first optional parameter
- Trailing lambdas for content slots
Recomposition:
- Triggered by State changes or parent recomposition
- Skipped when all parameters are stable and unchanged
- Scope: entire function re-executes, only changed UI nodes update
- Extract state reads into small composables for perf
Stability:
- val data classes with stable fields = stable
- var properties = unstable
- Unstable params force recomposition every time
CompositionLocal:
- Implicitly passes data down the tree
- Good for theme, auth, navigation
- Avoid for simple data passing