Why Custom Composables Matter in Jetpack Compose
When I first started working with Jetpack Compose at CodeBrew Labs, I made the same mistake most developers do: I treated composables like traditional XML layouts and tried to inline everything directly into screens. The result? UI code that was difficult to test, impossible to reuse, and a nightmare to maintain across multiple screens.
After shipping six production apps and leading teams through large-scale Android development projects, I learned that custom composables are the backbone of scalable Compose applications. They're not just about reducing code duplication—they're about creating a design system, enforcing consistency, and building an abstraction layer that lets you evolve your UI without touching screen-level code.
In this post, I'll share exactly how I structure custom composables to make them truly reusable, maintainable, and production-ready.
Composition Over Inheritance in Jetpack Compose
The first principle I adopted when building custom Jetpack Compose components is that composition is king. Unlike traditional Android views, Compose doesn't work well with inheritance-based patterns. Instead, you build complex UIs by composing smaller, single-purpose composables together.
The Function-Based Component Model
Every composable is a function. This might sound obvious, but it fundamentally changes how you approach component design. You're not extending base classes or overriding methods—you're writing pure functions that transform data into UI.
This has profound implications:
- No hidden state: All state is explicitly passed as parameters
- Testability: You can test composables like regular functions
- Reusability: Parameters make components flexible across contexts
- Preview-friendly: Easy to create preview functions with different states
I learned this the hard way when migrating a legacy app from Views to Compose. A component that seemed "simple" in the old codebase turned out to be a tangled web of inheritance and internal state. When I rewrote it as a pure composable, it became half the code and twice as flexible.
Designing Clean Composable APIs
Not all custom composables are created equal. I've seen reusable components that worked perfectly in isolation but became nightmares when teams tried to use them across multiple projects. The difference always came down to API design.
Core Principles for Composable APIs
1. Progressive Disclosure
Start with sensible defaults. A well-designed custom composable should work beautifully with minimal parameters. Advanced use cases can customize further.
2. Named Parameters Over Positional
Always use named parameters. It makes call sites self-documenting and prevents parameter ordering mistakes as you evolve the API.
3. Trailing Lambda Convention
If your composable accepts a lambda (especially for content), make it the last parameter. Kotlin's trailing lambda syntax makes the code cleaner and more readable.
4. Slot-Based Content API
Instead of forcing consumers to pass specific content types, use named lambda parameters ("slots") for flexibility. This is how Compose's built-in components work—TopAppBar has slots for title, navigationIcon, actions, etc.
Example: A Well-Designed vs. Poorly-Designed Component
Poor API:
// ❌ Hard to use, inflexible
@Composable
fun MyCard(title: String, subtitle: String, onClick: () -> Unit) {
// What if I don't need subtitle? What if I need custom actions?
}
Better API:
// ✅ Flexible, composable, reusable
@Composable
fun MyCard(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
header: @Composable () -> Unit = {},
content: @Composable () -> Unit
) {
Surface(
modifier = modifier.clickable(enabled = onClick != null) { onClick?.invoke() },
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface,
elevation = CardDefaults.cardElevation()
) {
Column(modifier = Modifier.padding(16.dp)) {
if (header != {}) {
header()
Spacer(Modifier.height(8.dp))
}
content()
}
}
}
State Management Patterns for Reusable Components
One of the trickiest aspects of building reusable custom composables is managing state properly. I've discovered three patterns that work well in production, and choosing the right one depends on your use case.
Pattern 1: Stateless (Presentation) Composables
These are pure functions with no internal state. Everything is passed in as parameters. They're the easiest to test and reuse, and I default to this pattern whenever possible.
@Composable
fun UserProfileCard(
user: User,
isSelected: Boolean,
onSelect: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(if (isSelected) Color.Blue else Color.White)
.clickable { onSelect() }
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(user.name, style = MaterialTheme.typography.headlineSmall)
Text(user.email, style = MaterialTheme.typography.bodySmall)
}
}
}
Pattern 2: Hoisted State (Controlled Composables)
When a composable needs to manage some internal state but you still want it reusable, hoist the state upward. The parent controls state; the composable reads and reports changes.
@Composable
fun ExpandableCard(
title: String,
isExpanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Surface(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.clickable { onExpandedChange(!isExpanded) }
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(title, style = MaterialTheme.typography.headlineSmall)
Icon(
if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null
)
}
if (isExpanded) {
Spacer(Modifier.height(8.dp))
content()
}
}
}
}
Pattern 3: Fully Stateful (Uncontrolled) Composables
Sometimes you want a composable that completely manages its own state. This is acceptable for components that are truly self-contained and don't need external synchronization. However, I use this sparingly because it's harder to test and reuse.
In my experience, hoisted state is the sweet spot—it gives you flexibility without losing control.
Real-World Custom Composable: Form Card Component
Let me walk you through a composable I built for EmpSuite ERP that demonstrates all these principles. It's a reusable form card that we used across dozens of screens.
@Composable
fun FormCard(
modifier: Modifier = Modifier,
title: String,
isLoading: Boolean = false,
error: String? = null,
onRetry: (() -> Unit)? = null,
content: @Composable () -> Unit
) {
Surface(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp)),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 4.dp
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Header
Text(
title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
// Content area
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
when {
isLoading -> {
CircularProgressIndicator(
modifier = Modifier.size(40.dp)
)
}
error != null -> {
Column(
modifier = Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.errorContainer,
RoundedCornerShape(8.dp)
)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
error,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
if (onRetry != null) {
Button(onClick = onRetry) {
Text("Retry")
}
}
}
}
else -> {
content()
}
}
}
}
}
}
Notice how this composable:
- Uses hoisted state: isLoading and error are passed in, not managed internally
- Handles edge cases: loading, error, and normal states out of the box
- Provides slots: The content parameter lets consumers put anything inside
- Has sensible defaults: Optional parameters with practical defaults
- Uses named parameters: Every parameter is self-documenting
I shipped this component across multiple projects at Raybit Technologies, and teams reused it without modification because the API was flexible enough to handle different requirements.
📖 Testing Custom Composables
One huge benefit of designing reusable custom composables this way is testability. Stateless and hoisted-state composables can be tested with Compose's testing framework (createComposeRule). I typically write tests that verify different state combinations render correctly without needing mocks or complex setup.
Key Takeaways
- Composition over inheritance: Build complex Jetpack Compose UIs by combining small, focused composables instead of extending base classes.
- Hoist state upward: Keep custom composables flexible by allowing parents to control state; this is the sweet spot between testability and reusability in Android architecture.
- Design APIs thoughtfully: Use named parameters, trailing lambdas, and slot-based content APIs to create intuitive, self-documenting composables that teams actually want to reuse.
- Start stateless: Default to stateless presentational composables; add state management only when necessary, and prefer hoisted state in most cases.
- Progressive disclosure: Make your custom composables work beautifully with sensible defaults; advanced customization should be opt-in, not mandatory.
Building reusable custom composables is one of the most valuable skills in modern Android development. It's the difference between shipping features fast and maintaining a codebase that scales. I've seen teams go from struggling with code duplication to shipping with confidence once they got these patterns right.