Skip to content

Recipe: Embed a reusable component that delegates navigation

Problem: you have a UI block reused across screens (a profile card, a list row, an embedded widget) that emits events — but it shouldn’t own navigation. The host screen should decide what a tap means.

Use SubCircuit for embedded components. The child gets no Navigator; it sends navigation and other host-owned events through an outerEventSink. That’s almost always what you want when one screen’s content is composed inside another.

Pick based on how independent the component is (background):

The component… Tool
is mostly standalone but defers navigation/dialogs to its host SubCircuit
must share live state with its siblings (selection mode, a shared filter) hoist into the parent presenter — see shared selection state
is a fully independent destination that just happens to render here, and needs its own Navigator bare CircuitContent

SubCircuit: the default for embedding

A SubPresenter receives an outerEventSink instead of a Navigator. The child needs no Parcelable screen.

// 1. Outer events the parent handles, plus a SubScreen key.
sealed interface ProfileCardOuterEvent : SubCircuitOuterEvent {
  data class OpenProfile(val userId: String) : ProfileCardOuterEvent
}

data class ProfileCardScreen(val userId: String) : SubScreen<ProfileCardOuterEvent>

// 2. SubPresenter receives outerEventSink instead of a Navigator.
class ProfileCardPresenter(private val screen: ProfileCardScreen) :
  SubPresenter<ProfileCardOuterEvent, ProfileCardState> {

  @Composable
  override fun present(outerEventSink: (ProfileCardOuterEvent) -> Unit): ProfileCardState =
    ProfileCardState(userId = screen.userId) { event ->
      when (event) {
        ProfileCardUiEvent.Clicked ->
          outerEventSink(ProfileCardOuterEvent.OpenProfile(screen.userId))
      }
    }
}

The host renders it with SubCircuitContent and maps outer events to its own events:

@Composable
fun TeamMembers(state: TeamMembersState, modifier: Modifier = Modifier) {
  LazyColumn(modifier) {
    items(state.members, key = { member -> member.id }) { member ->
      SubCircuitContent(
        screen = ProfileCardScreen(member.id),
        outerEventSink = { outerEvent ->
          when (outerEvent) {
            is ProfileCardOuterEvent.OpenProfile ->
              state.eventSink(TeamMembersEvent.OpenProfile(outerEvent.userId))
          }
        },
      )
    }
  }
}

This keeps the child reusable while leaving navigation decisions with the host.

Bare CircuitContent: the exception

Use plain CircuitContent only when the embedded content is a fully independent destination that needs its own Navigator, such as a self-contained section that navigates on its own.

@Composable
fun Dashboard(state: DashboardState, modifier: Modifier = Modifier) {
  Column(modifier) {
    CircuitContent(ProfileHeaderScreen(state.userId))   // runs its own presenter + UI, own nav
    CircuitContent(ActivityFeedScreen(state.userId))
  }
}

If you are reaching for CircuitContent’s onNavEvent overload to send child navigation up to the parent, use SubCircuit instead.

See also: SubCircuit · CircuitContent · Share selection state across list items