Skip to content

Recipe: Pull to refresh

Problem: the user pulls down to refresh a list. You need a refreshing flag that’s separate from the initial load, plus a way to trigger the refresh.

Keep isRefreshing as presenter state, set it when the refresh starts, and clear it when the data comes back. Trigger the actual work through the repository.

@Composable
override fun present(): FeedState {
  val scope = rememberCoroutineScope()
  var isRefreshing by rememberRetained { mutableStateOf(false) }

  val items by produceRetainedState<List<FeedItem>>(emptyList()) {
    feedRepository.items().collect { latest ->
      value = latest
      isRefreshing = false   // data arrived → stop the spinner
    }
  }

  return FeedState(items = items, isRefreshing = isRefreshing) { event ->
    when (event) {
      FeedEvent.Refresh -> {
        isRefreshing = true
        scope.launch { feedRepository.refresh() }   // short, UI-tied work — see note below
      }
    }
  }
}

In the UI, wire isRefreshing and the event to Compose’s PullToRefreshBox:

@Composable
fun Feed(state: FeedState, modifier: Modifier = Modifier) {
  PullToRefreshBox(
    isRefreshing = state.isRefreshing,
    onRefresh = { state.eventSink(FeedEvent.Refresh) },
    modifier = modifier,
  ) {
    LazyColumn {
      items(state.items, key = { item -> item.id }) { item -> FeedRow(item) }
    }
  }
}

Note

feedRepository.refresh() should be a quick trigger that causes the underlying Flow to re-emit. If it starts long-running work, that work belongs in the data layer, scoped to something that outlives the screen. rememberCoroutineScope() is cancelled when the presenter leaves composition. See run a one-shot suspend action.

See also: Retry a failed load · Observe a Flow · Compose pull-to-refresh