# How the Pull-to-Refresh Component Works with Jetpack Compose in GSYGitHubApp

> Explore how the PullToRefresh composable in GSYGitHubApp utilizes Jetpack Compose's PullToRefreshBox with derivedStateOf and LaunchedEffect for seamless refresh and load-more functionality.

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

---

**The `GSYPullRefresh` composable in the GSYGitHubApp repository wraps Material 3's `PullToRefreshBox` with automatic load-more detection using `derivedStateOf` and `LaunchedEffect` to handle both refresh and pagination in a single reusable component.**

The `carguo/gsygithubappcompose` repository demonstrates a production-ready implementation of pull-to-refresh functionality using Jetpack Compose. This article examines how the `GSYPullRefresh` component combines Material 3 APIs with custom scroll detection to create a seamless refresh and load-more experience.

## Core Architecture of GSYPullRefresh

The component is implemented in [`core/ui/src/main/java/com/shuyu/gsygithubappcompose/core/ui/components/GSYPullRefresh.kt`](https://github.com/carguo/gsygithubappcompose/blob/main/core/ui/src/main/java/com/shuyu/gsygithubappcompose/core/ui/components/GSYPullRefresh.kt) and follows a six-step architecture that separates UI state from data logic.

### Wrapping with PullToRefreshBox

At the top level, the composable uses Material 3's `PullToRefreshBox` (lines 59-66). It accepts an `isRefreshing` boolean and an `onRefresh` lambda. The refresh gesture is only forwarded when `!isLoadMore` prevents simultaneous refresh and load-more operations, ensuring data consistency during paging operations.

### Rendering the LazyColumn

Inside the box, a `LazyColumn` renders the actual list content (lines 68-76). The caller provides a customizable `LazyListState` and defines item rendering through the `content` lambda, ensuring full flexibility over list appearance while the component handles the container behavior.

### Load More Footer UI

After the list items, conditional logic (lines 77-124) renders a footer based on the paging state. Depending on `isLoadMore`, `loadMoreError`, `hasMore`, and `itemCount`, the component displays a loading spinner, an error retry button, a "loading more" text label, or a "no more data" indicator.

### Detecting Scroll Position

The component uses `derivedStateOf` (lines 30-38) to create a `shouldLoadMore` state. This efficiently tracks when the last visible item index reaches `totalItemsCount - 2`, indicating the user has scrolled near the bottom without triggering recomposition on every scroll pixel.

### Triggering Load More Operations

A `LaunchedEffect` (lines 50-54) observes `shouldLoadMore`. When true, and provided the list isn't empty, isn't already refreshing or loading, has more data available, and has no load-more errors, it invokes the `onLoadMore` callback to fetch the next page.

### State Management Pattern

Each feature screen (e.g., [`TrendingScreen.kt`](https://github.com/carguo/gsygithubappcompose/blob/main/TrendingScreen.kt) lines 34-42) maintains a ViewModel that exposes `isRefreshing`, `isLoadMore`, `hasMore`, and other states. The screen simply passes these values to `GSYPullRefresh`, maintaining strict separation between UI presentation and business logic.

## Implementation Examples

### Basic Usage with ViewModel

The standard pattern used throughout the app (Trending, Search, Profile) involves collecting UI state from a ViewModel and passing it to the component:

```kotlin
@Composable
fun ExampleScreen(viewModel: ExampleViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsState()

    GSYPullRefresh(
        isRefreshing = uiState.isRefreshing,
        onRefresh = { viewModel.refresh() },
        isLoadMore = uiState.isLoadingMore,
        onLoadMore = { viewModel.loadMore() },
        hasMore = uiState.hasMore,
        itemCount = uiState.items.size,
        loadMoreError = uiState.loadMoreError,
        contentPadding = PaddingValues(8.dp),
        verticalArrangement = Arrangement.spacedBy(4.dp)
    ) {
        items(uiState.items) { item ->
            // Render each item
            Text(text = item.title)
        }
    }
}

```

### Non-Paginated Lists

For screens like Trending where the API does not support pagination, set `isLoadMore` to `false` and provide no-op callbacks:

```kotlin
GSYPullRefresh(
    isRefreshing = uiState.isRefreshing,
    onRefresh = { viewModel.refresh() },
    isLoadMore = false,               // Trending API is not paginated
    onLoadMore = { /* no-op */ },
    hasMore = false,
    itemCount = uiState.repositories.size,
    loadMoreError = false,
    contentPadding = PaddingValues(5.dp),
    verticalArrangement = Arrangement.spacedBy(8.dp)
) {
    items(uiState.repositories) { repo ->
        RepositoryItem(repoItem = repo.toTrendingDisplayData())
    }
}

```

See the full screen implementation 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).

### Internal Scroll Detection Logic

The core mechanism combining `derivedStateOf` for efficient reading and `LaunchedEffect` for side effects:

```kotlin
PullToRefreshBox(
    isRefreshing = isRefreshing,
    onRefresh = {
        if (!isLoadMore) onRefresh()
    }
) {
    LazyColumn(state = listState, /* … */) {
        content()
        // Footer UI based on paging state
    }

    // Detect bottom reach
    val shouldLoadMore by remember {
        derivedStateOf {
            val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()
            last?.index ?: -1 >= listState.layoutInfo.totalItemsCount - 2
        }
    }

    LaunchedEffect(shouldLoadMore) {
        if (itemCount > 0 && shouldLoadMore && !isLoadMore && !isRefreshing && hasMore && !loadMoreError) {
            onLoadMore()
        }
    }
}

```

Full source available at [`core/ui/src/main/java/com/shuyu/gsygithubappcompose/core/ui/components/GSYPullRefresh.kt`](https://github.com/carguo/gsygithubappcompose/blob/main/core/ui/src/main/java/com/shuyu/gsygithubappcompose/core/ui/components/GSYPullRefresh.kt).

## Summary

- **Material 3 Integration**: The component delegates pull-to-refresh gestures to `PullToRefreshBox`, inheriting platform-standard animations and accessibility handling.
- **Automatic Pagination**: Uses `derivedStateOf` with `LazyListState` to detect when users reach the bottom two items, triggering `onLoadMore` via `LaunchedEffect`.
- **State Isolation**: UI state (`isRefreshing`, `isLoadMore`, etc.) is supplied by the calling screen's ViewModel, keeping the component purely presentational.
- **Flexible Content**: The `content` lambda accepts any `LazyListScope` items, allowing screens to customize list rendering while reusing the refresh and load-more logic.
- **Conflict Prevention**: Refresh operations are blocked during load-more and vice versa, preventing race conditions in data fetching.

## Frequently Asked Questions

### How does GSYPullRefresh detect when to load more data?

The component creates a `derivedStateOf` that observes `LazyListState.layoutInfo.visibleItemsInfo`. When the last visible item's index is greater than or equal to `totalItemsCount - 2`, the boolean `shouldLoadMore` becomes true. A `LaunchedEffect` monitoring this state triggers `onLoadMore()` only when all safety conditions (not loading, not refreshing, has more data, no errors) are met.

### Can I use GSYPullRefresh without pagination?

Yes. Set `isLoadMore = false`, `hasMore = false`, and provide an empty lambda for `onLoadMore`. The Trending screen 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) demonstrates this pattern for APIs that return complete datasets without pagination.

### Why use derivedStateOf instead of snapshotFlow for scroll detection?

`derivedStateOf` is optimized for reading state frequently without causing recomposition every time the underlying value changes. Since scroll position updates constantly during user interaction, `derivedStateOf` ensures the component only recomposes when the actual boolean condition (reaching near the bottom) changes, not on every pixel scrolled.

### How does the component prevent simultaneous refresh and load operations?

The `onRefresh` callback inside `PullToRefreshBox` checks `if (!isLoadMore)` before invoking the user-provided `onRefresh`. Similarly, the `LaunchedEffect` for load-more checks `!isRefreshing` before triggering `onLoadMore`. These guards ensure that only one data operation runs at a time, preventing race conditions and inconsistent UI states.