Sitemap
Lucky Raj Gurjar

Lucky Raj Gurjar

Scaling Jetpack Compose: 3 Non-Negotiable Rules for Enterprise UIs

How to establish architectural boundaries that keep your Android codebase clean, performant, and scale-ready.

3 min readApr 16, 2026

--

Press enter or click to view image in full size

Jetpack Compose has completely revolutionized Android UI development. It makes building custom components incredibly fast and reduces the boilerplate we used to deal with in XML layouts. But there is a hidden danger: without strict architectural boundaries, Compose makes it incredibly easy to create a tangled, unscalable mess of state.

When you are managing a team and reviewing pull requests on a large-scale application, you quickly realize that building a screen that looks right isn’t enough. It has to be built in a way that prevents unnecessary recompositions, isolates bugs, and allows multiple engineers to work on the codebase simultaneously without stepping on each other’s toes.

Here are three non-negotiable rules for scaling Jetpack Compose in a production environment.

1. State Goes Down, Events Go Up (Unidirectional Data Flow)

The single biggest source of bugs in Compose is composables attempting to manage their own complex state. If a composable doesn’t explicitly own a piece of state, it should never be able to mutate it.

To maintain clean architecture, you must strictly enforce Unidirectional Data Flow (UDF). Hoist the state to the ViewModel. Pass down immutable data to your composables, and pass up lambda functions for user actions.

❌ The Anti-Pattern:

@Composable
fun UserProfile() {
var isSubscribed by remember { mutableStateOf(false) }

Button(onClick = { isSubscribed = !isSubscribed }) {
Text(if (isSubscribed) "Unsubscribe" else "Subscribe")
}
}

✅ The Clean Approach:

@Composable
fun UserProfile(
isSubscribed: Boolean,
onSubscriptionClick: () -> Unit
) {
Button(onClick = onSubscriptionClick) {
Text(if (isSubscribed) "Unsubscribe" else "Subscribe")
}
}

By keeping the component stateless, it becomes instantly testable, highly reusable, and completely decoupled from your business logic.

2. Stop Passing Massive State Objects to Dumb Components

It is tempting to grab your entire UiState data class and pass it down the tree to every child composable. After all, it requires less typing. But this is a massive performance trap.

Get Lucky Raj Gurjar’s stories in your inbox

Join Medium for free to get updates from this writer.

If your UserProfileCard only needs a name and a profile photo URL, do not pass the entire UserUiState (which might also contain billing history, address, and preferences) into it. Pass only what the component explicitly needs to render.

❌ The Anti-Pattern:

@Composable
fun UserAvatar(uiState: UserUiState) {
AsyncImage(
model = uiState.profileImageUrl,
contentDescription = "Avatar for ${uiState.userName}"
)
}

If anything inside UserUiState changes—even a completely unrelated background property like an internal ID—UserAvatar will be forced to evaluate a recomposition.

✅ The Clean Approach:

@Composable
fun UserAvatar(imageUrl: String, userName: String) {
AsyncImage(
model = imageUrl,
contentDescription = "Avatar for $userName"
)
}

This reduces recomposition overhead and ensures your UserAvatar component can be used anywhere in the app, even if the data comes from a completely different ViewModel.

3. Keep Business Logic Out of the UI Layer

Your UI should be incredibly “dumb.” Its only job is to paint pixels on the screen based on the data it receives and to capture user clicks.

Your composables should never be formatting dates, filtering lists, calculating percentages, or determining visibility logic based on complex conditions. Do all the heavy lifting in the ViewModel (or Domain layer) using Coroutines, and expose the final, perfectly formatted strings and booleans directly to the UI.

❌ The Anti-Pattern:

@Composable
fun TransactionRow(transaction: Transaction) {
// Formatting currency and dates inside the UI layer
val formattedAmount = NumberFormat.getCurrencyInstance().format(transaction.amount / 100.0)
val formattedDate = SimpleDateFormat("MMM dd, yyyy").format(transaction.timestamp)

Text("Paid $formattedAmount on $formattedDate")
}

✅ The Clean Approach:

@Composable
fun TransactionRow(formattedAmount: String, formattedDate: String) {
Text("Paid $formattedAmount on $formattedDate")
}

Moving this logic to the ViewModel means you can write standard JUnit tests for your formatting rules without having to spin up an emulator or write UI tests. It also ensures that if your design team decides to change how dates are displayed, you only have to update a single mapper function rather than hunting down UI components.

Final Thoughts

Jetpack Compose is incredibly powerful, but that power requires discipline. Establishing these boundaries early on saves countless hours of debugging performance issues later. By keeping your components small, stateless, and ignorant of business logic, you ensure your codebase remains agile and ready for long-term growth.

--

--