Pull to Refresh in Jetpack Compose with Material 3

Use Material 3’s PullToRefreshBox to wrap a scrollable layout such as LazyColumn. Keep isRefreshing and the data refresh action in the screen state holder, pass them to the box, and provide an accessible button alternative in the app bar. Use rememberPullToRefreshState() only when you need to coordinate or customize the indicator.
The official pull-to-refresh guide documents PullToRefreshBox as the Material 3 container for manually refreshing scrollable content. Its API takes isRefreshing, onRefresh, an optional state, and an optional indicator.
Basic list refresh
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun ArticleList(
articles: List<ArticleUi>,
isRefreshing: Boolean,
onRefresh: () -> Unit,
onArticleClick: (String) -> Unit
) {
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = onRefresh
) {
LazyColumn(Modifier.fillMaxSize()) {
items(articles, key = { it.id }) { article ->
ArticleRow(
article = article,
onClick = { onArticleClick(article.id) }
)
}
}
}
}The composable renders state and forwards the event. A ViewModel or other state holder performs the refresh, updates isRefreshing, and supplies the new list. Do not launch network work directly from the gesture callback.
Customize the indicator only when needed
The default indicator is sufficient for most screens. Create a state only when you need to pass the same state to a custom indicator and the box.
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.pulltorefresh.Indicator
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.ui.Alignment
val pullState = rememberPullToRefreshState()
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = onRefresh,
state = pullState,
indicator = {
Indicator(
modifier = Modifier.align(Alignment.TopCenter),
isRefreshing = isRefreshing,
containerColor = MaterialTheme.colorScheme.primaryContainer,
color = MaterialTheme.colorScheme.onPrimaryContainer,
state = pullState
)
}
) {
LazyColumn(Modifier.fillMaxSize()) { /* rows */ }
}The guide explains that rememberPullToRefreshState() manages pull distance and should be shared between the box and a custom indicator. Do not create a new state inside the indicator.
Refresh is not every loading state
Pull-to-refresh is a user-requested refresh of already available content. Keep it distinct from initial full-screen loading, an empty successful result, and an error state. If refresh fails while old data is still visible, preserve the data and show a non-disruptive error or retry action instead of replacing the entire screen with a blank error view.
For Paging, wire onRefresh to the paging refresh action and use its load states to decide which feedback belongs on screen. The companion loading, empty, error, and retry guide covers those branches.
Accessibility and UX
The pull gesture is not available to every person or device. Android’s API reference demonstrates a refresh action in the top app bar; provide a labeled icon button or another visible action that calls the same onRefresh. Avoid triggering refresh automatically when the screen recomposes, and prevent repeated requests while isRefreshing is true if the state holder does not already coalesce them.
Keep the indicator’s colors theme-aware, retain enough contrast, and test at the top of a short list, a long list, an empty list, and after an error.
Common mistakes
- Wrapping non-scrollable content and expecting the pull gesture to work.
- Keeping refresh state only inside the indicator instead of the screen state.
- Replacing old content with a full-screen spinner during every manual refresh.
- Omitting an accessible refresh control outside the gesture.
- Reusing obsolete Material pull-refresh APIs when the project already uses Material 3.
FAQ
Do I always need rememberPullToRefreshState()?
No. PullToRefreshBox has a default state. Remember and pass one when customizing the indicator or otherwise coordinating its behavior.
What should onRefresh do?
It should dispatch a refresh event to the state owner. The UI receives the updated isRefreshing value and content through state.
Can it wrap a LazyColumn?
Yes. A LazyColumn is the standard scrollable content shown in the official examples.