You spent years building a solid
BaseFragment. Loading states, error handling, analytics hooks — all in one place. Then Compose showed up and said: "No more fragments." So where does all that shared logic go?
If you've been writing Android long enough, you know the BaseFragment pattern like an old friend. A common abstract class that every screen extended — handling loading overlays, snackbars, auth checks, toolbar setup, and whatever else your app needed consistently across screens.
Jetpack Compose is a paradigm shift, not just a UI toolkit upgrade. There are no fragments to subclass. No lifecycle hooks to override. Just functions calling functions. So the million-dollar question becomes: how do you share screen-level behavior across your entire app without inheritance?
The answer is: composition over inheritance — and Compose was designed for exactly this.
Before we design the Compose equivalent, let's be honest about what BaseFragment was really responsible for:
navigate() helpers, back stack managementUiState, handling one-time eventsIn Compose, each of these has a better, more surgical home. Let's tackle them one by one.
BaseScreen Composable — Your New BaseFragmentThe most direct translation is a wrapper composable that every screen routes through. Think of it as a higher-order composable that handles your cross-cutting concerns.
@Composable
fun BaseScreen(
uiState: UiState,
modifier: Modifier = Modifier,
onRetry: (() -> Unit)? = null,
content: @Composable () -> Unit
) {
Box(modifier = modifier.fillMaxSize()) {
when {
uiState.isLoading -> FullScreenLoader()
uiState.error != null -> ErrorScreen(
message = uiState.error,
onRetry = onRetry
)
else -> content()
}
}
}
Every screen uses it like this:
@Composable
fun HomeScreen(viewModel: HomeViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
BaseScreen(
uiState = uiState,
onRetry = viewModel::retry
) {
HomeContent(uiState.data)
}
}
Clean. No repetition. Change FullScreenLoader once and every screen in your app updates. This is your first and most impactful tool.
ScaffoldScreen — Encapsulating Common App ChromeMost apps have a consistent shell: top app bar, bottom navigation, maybe a FAB. Instead of re-specifying this on every screen, wrap Scaffold in a composable that accepts configuration.
data class ScreenConfig(
val title: String = "",
val showBackButton: Boolean = false,
val actions: List<TopBarAction> = emptyList(),
val fab: FabConfig? = null
)
@Composable
fun ScaffoldScreen(
config: ScreenConfig,
modifier: Modifier = Modifier,
onBackClick: () -> Unit = {},
content: @Composable (PaddingValues) -> Unit
) {
Scaffold(
modifier = modifier,
topBar = {
AppTopBar(
title = config.title,
showBackButton = config.showBackButton,
actions = config.actions,
onBackClick = onBackClick
)
},
floatingActionButton = {
config.fab?.let { FabButton(it) }
}
) { padding ->
content(padding)
}
}
Usage becomes declarative — screens just describe what they need, not how to build it:
@Composable
fun ProfileScreen(onBack: () -> Unit) {
ScaffoldScreen(
config = ScreenConfig(
title = "Profile",
showBackButton = true,
fab = FabConfig(icon = Icons.Default.Edit, onClick = { /* edit */ })
),
onBackClick = onBack
) { padding ->
ProfileContent(modifier = Modifier.padding(padding))
}
}
In the BaseFragment world, you often had a BaseViewModel with common streams:
abstract class BaseViewModel : ViewModel() {
protected val _uiState = MutableStateFlow(UiState())
val uiState = _uiState.asStateFlow()
protected val _events = Channel<UiEvent>()
val events = _events.receiveAsFlow()
}
This still makes sense in Compose. Define a shared interface and a concrete base, but make it leaner:
interface BaseViewModel {
val uiState: StateFlow<UiState>
val events: Flow<UiEvent>
fun retry()
}
abstract class AppViewModel : ViewModel(), BaseViewModel {
protected val _uiState = MutableStateFlow(UiState())
override val uiState = _uiState.asStateFlow()
private val _events = Channel<UiEvent>(Channel.BUFFERED)
override val events = _events.receiveAsFlow()
protected fun emitEvent(event: UiEvent) {
viewModelScope.launch { _events.send(event) }
}
protected fun setLoading(loading: Boolean) {
_uiState.update { it.copy(isLoading = loading) }
}
protected fun setError(message: String?) {
_uiState.update { it.copy(error = message, isLoading = false) }
}
}
Now every ViewModel in your app gets setLoading, setError, and emitEvent for free.
CompositionLocal — The Dependency Injection of the UI TreeCompositionLocal is Compose's answer to context-passing without prop drilling. It's perfect for injecting shared behavior that any composable in the tree might need: analytics, navigation, theming, feature flags.
// Define what's available app-wide
data class AppEnvironment(
val analytics: AnalyticsTracker,
val navigator: AppNavigator,
val featureFlags: FeatureFlags
)
val LocalAppEnvironment = compositionLocalOf<AppEnvironment> {
error("No AppEnvironment provided")
}
// Provide it once at the root
@Composable
fun AppRoot() {
val environment = remember { AppEnvironment(/* inject deps */) }
CompositionLocalProvider(LocalAppEnvironment provides environment) {
AppNavGraph()
}
}
// Consume it anywhere, without passing it through every function
@Composable
fun PurchaseButton(productId: String) {
val analytics = LocalAppEnvironment.current.analytics
Button(onClick = {
analytics.track("purchase_clicked", mapOf("product_id" to productId))
}) {
Text("Buy Now")
}
}
This is especially powerful for analytics — you never have to thread an analytics tracker through fifteen composables again.
observe Event HandlingOne of the trickiest parts of BaseFragment was handling one-time events (show a toast, navigate, open a dialog). In Compose, LaunchedEffect + a Channel-backed flow is the canonical approach — and it belongs in a reusable handler.
@Composable
fun <VM : AppViewModel> HandleEvents(
viewModel: VM,
onNavigate: (destination: String) -> Unit = {},
onShowMessage: (message: String) -> Unit = {}
) {
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is UiEvent.Navigate -> onNavigate(event.destination)
is UiEvent.ShowToast -> Toast.makeText(
context, event.message, Toast.LENGTH_SHORT
).show()
is UiEvent.ShowSnackbar -> onShowMessage(event.message)
}
}
}
}
Use it alongside BaseScreen:
@Composable
fun CheckoutScreen(
viewModel: CheckoutViewModel = hiltViewModel(),
onNavigate: (String) -> Unit
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
HandleEvents(viewModel, onNavigate = onNavigate)
BaseScreen(uiState = uiState, onRetry = viewModel::retry) {
CheckoutContent(uiState.data, onConfirm = viewModel::confirm)
}
}
rememberXFor UI logic that isn't ViewModel-level but is repeated across screens — think pull-to-refresh state, pagination scroll position, selection state — extract it into a remember-based state holder.
class PaginatedListState(
initialItems: List<Any> = emptyList()
) {
var items by mutableStateOf(initialItems)
private set
var isLoadingMore by mutableStateOf(false)
private set
var hasMore by mutableStateOf(true)
private set
fun onLoadMore(newItems: List<Any>) {
items = items + newItems
isLoadingMore = false
hasMore = newItems.isNotEmpty()
}
fun setLoadingMore() { isLoadingMore = true }
}
@Composable
fun rememberPaginatedListState(
initialItems: List<Any> = emptyList()
): PaginatedListState = remember { PaginatedListState(initialItems) }
Any screen with an infinite scroll list composes this in — no inheritance, no copy-pasting.
Here's what a fully-equipped screen looks like with all these patterns composing together:
@Composable
fun OrderHistoryScreen(
viewModel: OrderHistoryViewModel = hiltViewModel(),
onNavigate: (String) -> Unit,
onBack: () -> Unit
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val listState = rememberPaginatedListState()
// Handle one-time events
HandleEvents(viewModel, onNavigate = onNavigate)
// Consistent app chrome
ScaffoldScreen(
config = ScreenConfig(title = "Orders", showBackButton = true),
onBackClick = onBack
) { padding ->
// Consistent loading/error/content switching
BaseScreen(
uiState = uiState,
onRetry = viewModel::retry,
modifier = Modifier.padding(padding)
) {
OrderList(
orders = uiState.orders,
listState = listState,
onLoadMore = viewModel::loadNextPage
)
}
}
}
This screen has zero boilerplate for loading states, error handling, scaffold chrome, event handling, or analytics. All of it is inherited through composition.
Here's a comparison to solidify the mapping:
|
BaseFragment Pattern |
Compose Equivalent |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
Dependency via constructor/DI |
|
|
Shared lifecycle observers |
|
|
Shared UI utilities |
|
Don't overload BaseScreen. Keep it focused on loading/error/content. The moment you add "check auth state" or "request permission" to it, you've rebuilt BaseFragment's worst habits.
Prefer flat composition over deep nesting. If you find yourself with BaseScreen inside ScaffoldScreen inside HandleEvents inside PermissionWrapper, extract a StandardScreen composable that composes all of them in one call.
CompositionLocal is not a global state store. It's for stable, rarely-changing dependencies. Don't put mutable screen state in it.
Test each layer independently. The beauty of this approach is that BaseScreen is a pure composable you can test with composeTestRule without any ViewModel involved. Keep it that way.
The BaseFragment era gave us a blunt instrument for code sharing — inheritance. Jetpack Compose gives us something far more powerful: a composable function is both a unit of UI and a unit of logic, and it can be composed, layered, and parameterized without ever subclassing anything.
The patterns here — BaseScreen, ScaffoldScreen, AppViewModel, HandleEvents, CompositionLocal, and rememberX — aren't just replacements for BaseFragment. They're upgrades. Each piece is independently testable, independently reusable, and independently evolvable.
Stop looking for the Compose equivalent of BaseFragment. Start composing the pieces that were always hiding inside it.
If this was useful, follow me for more deep dives into real-world Compose architecture. Questions or battle-tested patterns of your own? Drop them in the comments.