Circuit

repository·main·Indexed 23 days ago

https://github.com/slackhq/circuit

A Compose-based framework by Slack Technologies for managing navigation and state. It includes a backstack implementation, a KSP plugin (circuit-codegen) for automating Ui.Factory and Presenter.Factory generation with DI support (Dagger, Hilt, Anvil, kotlin-inject, Metro), and state retention APIs via circuit-retained. It also provides serialization support for persisting navigation state using kotlinx-serialization and reflective serialization.

Tokens
57.3K
Snippets
154
Records
215
Agent score
82%

What's inside Circuit

  1. What is CircuitX?

    main

    CircuitX is a suite of extension artifacts for the Circuit framework. It provides 'batteries-included' implementations for common use cases that are not part of the Circuit core. This includes ready-to-use Overlay types, Android navigation interop, and other specialized utilities.

    Key characteristics of CircuitX artifacts:

    • Package Prefix: All artifacts use the com.slack.circuitx package prefix.
    • API Stability: APIs in CircuitX may change more frequently than Circuit core as they evolve alongside the framework.
    • Platform Specificity: Some artifacts are designed specifically for certain platforms (e.g., Android).
    • Baseline Profiles: Unlike core Circuit artifacts, CircuitX artifacts do not ship with their own baseline profiles.
  2. Explore Circuit sample projects

    main

    Circuit provides several sample projects to demonstrate different use cases and patterns:

    • star: A non-trivial multiplatform app for Social Tees Animal Rescue (STAR) demonstrating real-world usage.
    • counter: A simple multiplatform counter circuit that increments or decrements a count.
    • inbox: An adaptive list-detail email app demonstrating composite presenters and adaptive layouts.
    • interop: Examples of integrating different paradigms into Circuit using a Counter example.
    • tacos: A food ordering flow/wizard app demonstrating composite presenters and nested UIs.
    • tutorial: A step-by-step guide to creating a simple Circuit app (online tutorial available at https://slackhq.github.io/circuit/tutorial/).
  3. Identify Presenter complexity in Circuit

    main

    As a Circuit application grows, presenters can become difficult to maintain. You should look for the following signs of complexity to determine if you need to apply scaling patterns:

    • Event sink explosion: The number of events required for each mutable state value grows too quickly.
    • Internal state sprawl: State defined within the present() function makes it difficult to extract event handling into smaller, manageable functions.
    • Boolean flag soup: An excessive number of boolean flags (e.g., showWarningBanner, showBottomSheetA) leads to overly complex UI conditional logic.
    • Testing difficulties: High property counts in the state make comprehensive unit testing difficult.

    A healthy presenter should adhere to Single Responsibility (handling only presentation logic), be Testable (isolated unit tests with clear I/O), and be Maintainable (easy to extend and understand).

  4. How Circuitx navigation interception works

    main

    Circuitx navigation provides an optional intercepting system that sits before a standard Circuit Navigator. This allows you to inspect, modify, or block navigation events before they are executed.

    The Interception Lifecycle

    1. Trigger: A navigation event occurs (e.g., goTo(), pop(), or resetRoot()).
    2. Interception: The event is passed through all registered NavigationInterceptors in order.
    3. Interceptor Decision: Each interceptor can:
      • Skip: Pass the event to the next interceptor.
      • Consume: Handle the event and prevent further processing (by subsequent interceptors or the base navigator).
      • Rewrite: Change the destination. If an interceptor returns a Rewrite result, the process restarts with the new destination as if it were a fresh navigation event.
    4. Execution: If no interceptor consumes the event, it is passed to the underlying Navigator.
    5. Notification: NavigationEventListeners are notified of the successful navigation change.
  5. Create a UI

    main

    A Ui is responsible for rendering the state. You can implement a UI in two ways:

    1. As a Composable function: A simple top-level function that takes State and Modifier as parameters. This is the recommended approach for most use cases.
    2. As a class: Implement the Ui<State> interface. This is useful for more complex UIs that require dependencies.

    Example (Composable function):

    @Composable
    fun Inbox(state: InboxScreen.State, modifier: Modifier = Modifier) {
      // Render state using standard Compose components
    }
    @Composable
    fun Inbox(state: InboxScreen.State, modifier: Modifier = Modifier) {
      Scaffold(modifier = modifier, topBar = { TopAppBar(title = { Text("Inbox") }) }) {
        LazyColumn(modifier = Modifier.padding(innerPadding)) {
          items(state.emails) { email ->
            EmailItem(email)
          }
        }
      }
    }
  6. How Circuit testing helpers work

    main

    Circuit is designed to minimize the need for mocking. Instead, it provides specific test artifacts and fakes to bridge the gap between Compose, Coroutines, and testing frameworks:

    • presenterTestOf(): A top-level function that wraps a composable function to bridge the Compose and coroutines world. It uses Molecule and Turbine. It returns a CircuitReceiveTurbine, which is a custom ReceiveTurbine that only emits changed items (applying distinctUntilChanged logic).
    • Presenter.test(): An extension function on Presenter that serves as a shorthand for presenterTestOf().
    • FakeNavigator: A test implementation of the Navigator interface. Use this to test screen navigation (e.g., goTo, pop/back). It records navigation actions so you can assert on them.
    • TestEventSink: A generic test fake used to record and assert event emissions through an event sink function.
  7. Use StaticScreen for UIs without a Presenter

    main

    If a UI does not require a presenter to compute or manage its state (for example, if it is stateless or derives state directly from a Screen's properties), you should make your Screen implement the StaticScreen interface.

    When a StaticScreen is used, Circuit allows the UI to run independently and will not attempt to connect it to a presenter if one is not provided.

  8. Choose the correct retention function for state

    main

    Circuit provides three types of composable retention functions to manage state lifecycle. Choosing the right one depends on whether you need to survive recompositions, back stack navigation, configuration changes, or process death.

    Comparison Table

    FeaturerememberrememberRetainedrememberSaveable
    Recompositions
    Back stack✅*✅*
    Configuration changes
    Process death
    Non-Saveable types

    *Note: Back stack retention assumes NavigableCircuitContent's default configuration.

    Usage Details

    1. remember: Standard Compose function. Remembers a value across recompositions only. Use this for transient UI state that doesn't need to survive navigation.
    2. rememberRetained: A custom Circuit function. Remembers a value across recompositions, the back stack, and configuration changes (e.g., screen rotation). On Android, this is backed by a hidden ViewModel. Do not retain leakable objects like Navigator or Context.
    3. rememberSaveable: Standard Compose function. Remembers a value across recompositions, the back stack, configuration changes, and process death. It is backed by the framework's saved instance state system. The value must be a primitive, Parcelable (on Android), or use a custom Saver. Do not retain leakable objects like Navigator or Context.
  9. How persistence and navigation state are handled in bottom-navigation

    main

    The bottom-navigation sample implements state persistence using buildCircuitSaver(), which creates a SerializableCircuitSaver.

    To ensure navigation history is preserved, every screen is registered under polymorphic(CircuitSaveable::class). This saver is then explicitly passed to rememberSaveableNavStack() on each platform.

    Important Implementation Note for Android: As of version 0.35, screens must implement both @Serializable (for general serialization/persistence) and @Parcelize (to satisfy Android's Parcelable requirement). The requirement for @Parcelize is expected to be removed in a future release.

  10. Implement cursor-based pagination with PagingState

    main

    To implement a 'load more on scroll' pattern, use a PagingState<T> holder to manage accumulated items, the next cursor, and loading flags. This holder should be a plain @Stable class that stores only data. To avoid memory leaks, do not pass a repository into the PagingState constructor; instead, pass the fetching function as a parameter to the loadNext() method. This ensures the holder remains lightweight and doesn't capture large objects like repositories or Context in retained state.

    Key properties:

    • items: The list of accumulated items.
    • isLoadingMore: An observable boolean for showing loading indicators.
    • endReached: A boolean indicating if no more pages are available.

    Use Mutex.withLock inside loadNext() to prevent duplicate requests if multiple load events are triggered simultaneously.

    @Stable
    class PagingState<T> {
      private val loaded = mutableStateListOf<T>()
      val items: List<T> get() = loaded
    
      var isLoadingMore by mutableStateOf(false)
        private set
      var endReached by mutableStateOf(false)
        private set
    
      private var nextCursor: String? = null
      private val mutex = Mutex()
    
      // Pass the fetcher per call so the retained holder only stores paging data.
      suspend fun loadNext(fetchPage: suspend (cursor: String?) -> Page<T>) {
        // Return early when another load is already running.
        if (endReached || isLoadingMore) return
        mutex.withLock {
          if (endReached) return
          isLoadingMore = true
          try {
            val page = fetchPage(nextCursor)
            loaded.addAll(page.items)
            nextCursor = page.nextCursor
            endReached = page.nextCursor == null
          } finally {
            isLoadingMore = false
          }
        }
      }
    }