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.
What Were We Actually Doing With BaseFragment?
Before we design the Compose equivalent, let’s be honest about what BaseFragment was really responsible for:
- Common UI chrome — loading indicators, error states, empty states
- Navigation utilities —
navigate()helpers, back stack management - ViewModel wiring — observing
UiState, handling one-time events - Side effects — analytics, logging, permission checks
- Scaffold setup — toolbar, bottom nav, FAB behavior
In Compose, each of these has a better, more surgical home. Let’s tackle them one by one.
Pattern 1: The BaseScreen Composable — Your New BaseFragment
The 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.
Pattern 2: ScaffoldScreen — Encapsulating Common App Chrome
Most 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))
}
}
Pattern 3: Composable + ViewModel Contract — Replacing the Abstract ViewModel
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.
Pattern 4: CompositionLocal — The Dependency Injection of the UI Tree
CompositionLocal 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.
Pattern 5: One-Time Events — Replacing observe Event Handling
One 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)
}
}
Pattern 6: Reusable State Containers With rememberX
For 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.
Putting It All Together: A Complete Screen
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.
The Mental Model Shift
Here’s a comparison to solidify the mapping:
|
BaseFragment Pattern |
Compose Equivalent |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
Dependency via constructor/DI |
|
|
Shared lifecycle observers |
|
|
Shared UI utilities |
|
What to Watch Out For
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.
Conclusion
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.