# How to Handle Loading, Error, and Empty States in Jetpack Compose: A Complete Guide

> Master Jetpack Compose UI states. Learn how GSYGitHubApp declaratively manages loading, error, and empty states with a reusable composable for smoother user experiences.

- Repository: [Shuyu Guo/gsygithubappcompose](https://github.com/carguo/gsygithubappcompose)
- Tags: how-to-guide
- Published: 2026-02-26

---

**The GSYGitHubApp manages transient UI states through a centralized `GSYGeneralLoadState` composable that declaratively switches between loading spinners, error messages with retry actions, and main content based on simple Boolean and nullable parameters.**

The `carguo/gsygithubappcompose` repository demonstrates a production-ready approach to handling **loading, error, and empty states in Compose screens** using a single-source-of-truth pattern. By isolating state logic into a reusable wrapper component, every feature screen maintains consistent error handling and loading indicators without duplicating UI code.

## Centralized State Management with `GSYGeneralLoadState`

The architecture centers on a single composable that acts as a gatekeeper for all screen-level state transitions.

### The Wrapper Composable Implementation

Located at [`core/ui/src/main/java/com/shuyu/gsygithubappcompose/core/ui/components/GSYGeneralLoadState.kt`](https://github.com/carguo/gsygithubappcompose/blob/main/core/ui/src/main/java/com/shuyu/gsygithubappcompose/core/ui/components/GSYGeneralLoadState.kt), the `GSYGeneralLoadState` function accepts four parameters that completely define the screen's transient state:

- **`isLoading`**: Boolean flag indicating active network or processing operations
- **`error`**: Nullable string containing error messages
- **`retry`**: Lambda function invoked when users tap the retry button
- **`content`**: The actual screen UI to render when neither loading nor error states are active

The implementation uses a `Box` with centered alignment to overlay states:

```kotlin
@Composable
fun GSYGeneralLoadState(
    isLoading: Boolean,
    error: String?,
    retry: () -> Unit,
    content: @Composable () -> Unit
) {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        when {
            isLoading -> CircularProgressIndicator()
            error != null -> Column(
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                Text(
                    text = error,
                    color = MaterialTheme.colorScheme.error
                )
                Button(onClick = retry) {
                    Text("Retry")
                }
            }
            else -> content()
        }
    }
}

```

This design ensures that **loading and error handling remain consistent** across every screen while preserving the ability to customize the main content layout.

## Screen-Level Integration Patterns

All feature screens wrap their UI inside `GSYGeneralLoadState` after being hosted by `BaseScreen`, which provides common ViewModel-level behaviors such as toast message handling.

### TrendingScreen Implementation

The trending feature demonstrates conditional loading logic that distinguishes between initial page loads and subsequent pagination. In [`feature/trending/src/main/java/com/shuyu/gsygithubappcompose/feature/trending/TrendingScreen.kt`](https://github.com/carguo/gsygithubappcompose/blob/main/feature/trending/src/main/java/com/shuyu/gsygithubappcompose/feature/trending/TrendingScreen.kt), the loading state activates only when the first page is being fetched and the repository list is still empty:

```kotlin
BaseScreen(viewModel = viewModel) {
    GSYGeneralLoadState(
        isLoading = uiState.isPageLoading && uiState.repositories.isEmpty(),
        error = uiState.error,
        retry = { viewModel.refresh() }
    ) {
        GSYPullRefresh(
            isRefreshing = uiState.isRefreshing,
            onRefresh = { viewModel.refresh() }
        ) {
            // LazyColumn content displays when loaded
            items(uiState.repositories) { repo ->
                RepositoryItem(repo.toTrendingDisplayData())
            }
        }
    }
}

```

If the request fails, the error UI with a retry button appears immediately. When loading succeeds, the pull-to-refresh list renders; an empty list simply results in no visible items.

### SearchScreen and InfoScreen Variations

The search functionality in [`feature/search/src/main/java/com/shuyu/gsygithubappcompose/feature/search/SearchScreen.kt`](https://github.com/carguo/gsygithubappcompose/blob/main/feature/search/src/main/java/com/shuyu/gsygithubappcompose/feature/search/SearchScreen.kt) uses the same wrapper but checks multiple data sources:

```kotlin
GSYGeneralLoadState(
    isLoading = isPageLoading && repositoryResults.isEmpty() && userResults.isEmpty(),
    error = error,
    retry = { searchViewModel.performSearch() }
) {
    // Combined repository and user results list
}

```

Similarly, `InfoScreen` (user profile) handles single-object fetching in [`feature/info/src/main/java/com/shuyu/gsygithubappcompose/feature/info/InfoScreen.kt`](https://github.com/carguo/gsygithubappcompose/blob/main/feature/info/src/main/java/com/shuyu/gsygithubappcompose/feature/info/InfoScreen.kt):

```kotlin
GSYGeneralLoadState(
    isLoading = uiState.isPageLoading && uiState.user == null,
    error = uiState.error,
    retry = { viewModel.refresh() }
) {
    // Profile content list
}

```

## Handling Empty Data States

The **empty state** is handled implicitly rather than through explicit UI components. When the `content` lambda receives an empty list (e.g., `uiState.repositories.isEmpty()`), the composable simply draws nothing inside the `LazyColumn` or container.

This approach keeps the UI lightweight and allows each screen to decide whether to render a rich placeholder. The current implementation opts for a minimal blank view as the default empty state, though developers could easily extend the pattern by checking list emptiness within the content block or creating a dedicated `GSYEmptyState` composable.

## Benefits of This Architecture

- **Declarative state management**: Each screen declares *when* it is loading or errored through simple boolean expressions, avoiding embedded UI logic throughout the layout hierarchy.
- **Visual consistency**: `GSYGeneralLoadState` ensures identical spinner colors, error text styling, and retry button appearances across all features.
- **Compose-friendly composition**: The wrapper integrates seamlessly with other high-order components like `GSYPullRefresh` and `LazyColumn` without interfering with their internal state mechanics.
- **Extensibility**: Future enhancements can modify the central wrapper or add explicit empty-state branches without refactoring individual screens.

## Summary

- **`GSYGeneralLoadState`** in [`core/ui/src/main/java/com/shuyu/gsygithubappcompose/core/ui/components/GSYGeneralLoadState.kt`](https://github.com/carguo/gsygithubappcompose/blob/main/core/ui/src/main/java/com/shuyu/gsygithubappcompose/core/ui/components/GSYGeneralLoadState.kt) provides the centralized mechanism for rendering loading, error, and content states.
- Screens wrap their content inside this composable after `BaseScreen` setup, passing Boolean loading flags and nullable error strings derived from ViewModel state.
- **Loading states** typically combine `isPageLoading` flags with emptiness checks to avoid showing spinners during pagination.
- **Error states** display a retry button that invokes ViewModel refresh methods, centralizing error recovery logic.
- **Empty states** are implicitly handled by rendering blank content when data lists are empty, keeping the implementation lightweight.

## Frequently Asked Questions

### How does `GSYGeneralLoadState` differ from using `LaunchedEffect` for loading states?

`GSYGeneralLoadState` is a **presentational wrapper** that reacts to state changes through recompositions, while `LaunchedEffect` is used for side effects like triggering network requests. The composable receives `isLoading` as a parameter and declaratively chooses which UI to display, making the loading state a function of the UI layer rather than an imperative side effect.

### Can I customize the error UI for specific screens while keeping the wrapper?

Yes. While the current implementation uses a standard error message and retry button, you could extend `GSYGeneralLoadState` to accept an optional `@Composable errorContent: @Composable (String, () -> Unit) -> Unit` parameter. This would allow individual screens to override the default error UI while maintaining the consistent loading state behavior.

### Why is the empty state handled implicitly rather than explicitly in the wrapper?

The repository's design treats empty data as valid content—a list with zero items is still successfully loaded content. This avoids conflating "no data" with "error states" and gives screens flexibility: some may want to show "No results" illustrations while others prefer blank space. If explicit empty states become necessary, developers can add a conditional branch inside the `content` lambda or enhance `GSYGeneralLoadState` with an additional `isEmpty` parameter.

### How does this pattern handle pull-to-refresh alongside loading states?

The architecture layers `GSYGeneralLoadState` outside `GSYPullRefresh`. The wrapper handles the **initial page load** (full-screen spinner), while the pull-refresh component manages **subsequent refresh indicators** within the content area. This separation prevents conflicting loading indicators and ensures users see appropriate feedback for initial loads versus content updates.