Jetpack Compose Rules

repository·main·Indexed 20 days ago

https://github.com/mrmans0n/compose-rules

A collection of static analysis rules for @Composable functions designed to detect common pitfalls and enforce best practices in Jetpack Compose development. It is a fork of the original Twitter Jetpack Compose Rules and integrates with linting tools such as ktlint and detekt.

Tokens
13.7K
Snippets
43
Records
66
Agent score
71%

What's inside compose-rules

  1. Overview of Jetpack Compose Rules

    main
    Jetpack Compose Rules provides static analysis for @Composable functions to detect potential issues and 'footguns' early in the development cycle. It is designed to help teams adopt Jetpack Compose by enforcing best practices through automated checks. The project is a fork of the original Twitter Jetpack Compose Rules and is compatible with both ktlint and detekt for integration into your build process.
  2. Overview of Compose Rules

    main
    Compose Rules is a collection of custom static analysis rules designed for ktlint and detekt. It specifically targets @Composable functions to detect common pitfalls and 'footguns' in Jetpack Compose code. By integrating these rules into your linting workflow, you can catch potential issues early in the development cycle, before they reach code review, helping teams maintain consistent patterns and avoid common mistakes.
  3. Follow Composable naming and parameter conventions

    main

    Standardize your naming to make implicit dependencies and function purposes clear:

    • Composable functions: If the function returns Unit (emits UI), use PascalCase (e.g., Avatar). If it returns a value, use camelCase (e.g., calculateOffset).
    • CompositionLocals: Prefix names with Local (e.g., LocalTheme).
    • Multipreview annotations: Prefix with Previews (e.g., @PreviewsLightDark).
    • Custom Composable annotations: Use the Composable suffix (e.g., @MyComponentComposable).
    • Event parameters: Use the on + verb pattern in present tense (e.g., onClick, onTextChanged).
    • Parameter ordering:
      1. Required parameters
      2. modifier: Modifier = Modifier
      3. Optional parameters
      4. Trailing lambda (content slot)
  4. Follow Composable emission and layout rules

    main

    To ensure predictable UI and performance, follow these rules regarding how Composables emit content:

    • Emit XOR Return: A Composable should either emit layout content OR return a value, but never both. If you need to provide control surfaces, use parameters.
    • Emit a single layout node: A Composable should ideally emit zero or one layout node to remain cohesive. If a function emits multiple nodes (e.g., Text, Image, and Button directly), it becomes difficult to use in different parent layouts like Row or Box. Wrap them in a single layout (like Column) instead.
    • Hoist single conditional layouts: If a layout's only purpose is to wrap a conditional child, move the condition outside the layout to avoid emitting an empty container.

    Example: Hoisting conditions

    // ❌ The Column exists only to wrap a conditional child.
    @Composable
    fun Content(showMessage: Boolean) {
        Column {
            if (showMessage) {
                Text("Hello")
            }
        }
    }
    
    // ✅ Hoist the condition so the layout is only emitted when it has content.
    @Composable
    fun Content(showMessage: Boolean) {
        if (showMessage) {
            Column {
                Text("Hello")
            }
        }
    }
  5. Ensure state is remembered in composables

    main
    When using mutableStateOf or other State<T> builders within a @Composable function, you must wrap the instance in remember. If you do not use remember, a new state instance will be created every time the function recomposes, causing the state to be lost.
  6. Avoid deeply nested composables

    main

    To keep composables readable and maintainable, avoid deep nesting of content emitters. If a function becomes too deeply nested, extract inner sections into private @Composable functions.

    Configuration: The nesting threshold defaults to 3. You can tune this value using:

    • Detekt: composableNestingDepthThreshold
    • ktlint (.editorconfig): compose_composable_nesting_depth_threshold

    Rule Identifiers:

    • ktlint: compose:composable-nesting-depth-check
    • detekt: ComposableNestingDepth
    // ❌ Too deeply nested
    @Composable
    fun Foo() {
        Box {
            Box {
                Box {
                    Box {
                        Box {
                            Text("hello")
                        }
                    }
                }
            }
        }
    }
    
    // ✅ Better: Inner sections extracted to a private composable
    @Composable
    fun Foo() {
        Box {
            Box {
                Box {
                    Bar()
                }
            }
        }
    }
    
    @Composable
    private fun Bar() {
        Box {
            Box {
                Text("hello")
            }
        }
    }
  7. Hoist state in Jetpack Compose

    main

    Compose follows a unidirectional data flow pattern: state flows down to child composables, and events fire up to parents. To maintain stateless composables and improve testability, you should hoist state upwards.

    Avoid passing the following down to composables:

    • ViewModel instances (or objects from Dependency Injection).
    • MutableState<T> instances.
    • State<T> instances.
    • Inherently mutable types that cannot be observed by the Compose snapshot system.

    Instead: Pass the raw data required by the function and use lambdas (callbacks) to handle events.

    // Instead of passing a ViewModel or MutableState, pass the value and a callback
    @Composable
    fun MyComponent(count: Int, onIncrement: () -> Unit) {
        Button(onClick = onIncrement) {
            Text("$count")
        }
    }
  8. How functional tests work with Gradle TestKit

    main

    The functional tests use Gradle TestKit to simulate a real development environment. The process follows these steps:

    1. Project Creation: A temporary directory is created with a complete Gradle project structure (including build.gradle.kts and source files).
    2. Execution: Gradle commands are run via GradleRunner (e.g., spotlessKotlinCheck or detekt).
    3. Assertion: The tests assert on task outcomes (SUCCESS, FAILED, UP_TO_DATE) and inspect build output content to ensure rules are correctly detecting violations.

    Example Test Pattern:

    @Test
    fun `ModifierMissing is detected via ktlint`() {
        setupKtlintProject()
    
        projectDir.writeFile(
            "src/main/kotlin/Violations.kt",
            """
            @Composable
            fun MissingModifier() {
                Row { }  // Missing modifier parameter
            }
            """
        )
    
        val result = createGradleRunner(
            projectDir = projectDir,
            arguments = listOf("spotlessKotlinCheck")
        ).buildAndFail()
    
        result.assertOutputContains("compose:modifier-missing-check")
    }
  9. Avoid using unstable collections

    main

    Standard Kotlin collection interfaces (List<T>, Map<T>, Set<T>) are considered unstable by the Compose compiler because they cannot guarantee immutability. To ensure stability and prevent unnecessary recompositions, use one of the following approaches:

    1. Use Kotlinx Immutable Collections (Preferred): Use types like ImmutableList<T> with persistentListOf().
    2. Wrap in a stable class: Wrap a standard collection in a data class annotated with @Immutable.
    3. Stability Configuration: Alternatively, add kotlin.collections.* to your project's stability configuration to treat them as stable.

    Rule Identifiers:

    • ktlint: compose:unstable-collections
    • detekt: UnstableCollections
    // ❌ Unstable: The compiler cannot guarantee immutability
    val list: List<String> = mutableListOf()
    
    // ✅ Stable: Using Kotlinx Immutable Collections
    val list: ImmutableList<String> = persistentListOf<String>()
    
    // ✅ Stable: Wrapping in an @Immutable class
    @Immutable
    data class StringList(val items: List<String>)
    val list: StringList = StringList(yourList)
  10. Correct modifier order for UI behavior

    main

    The order of modifier functions matters because each function transforms the Modifier returned by the previous one. For example, if you apply .clickable {} before .clip(), the click ripple will not be constrained to the clipped shape.

    To ensure the ripple or background respects the shape, apply clipping and background modifiers before the clickable modifier.

    This rule is enforced by compose:modifier-clickable-order.

    // ✅ Correct: Clip and background are applied first, then clickability
    @Composable
    fun MyCard(modifier: Modifier = Modifier) {
        Column(
            modifier
                .clip(shape = RoundedCornerShape(8.dp))
                .background(color = backgroundColor, shape = RoundedCornerShape(8.dp))
                .clickable { /* TODO */ }
        ) {
            // ...
        }
    }
  11. Avoid using mutable types or State as Composable parameters

    main

    To maintain unidirectional data flow and ensure recomposition works correctly, avoid passing mutable objects or state holders as parameters to @Composable functions.

    • Do not use inherently mutable types: Avoid passing types like ArrayList<T> or ViewModel directly. Mutations to these objects often fail to trigger recomposition. Instead, pass immutable values and use lambda callbacks for mutations.
    • Do not use MutableState<T>: Passing MutableState splits state ownership. Instead, make the component stateless by passing the raw value and an event callback.
    • Do not use State<T>: Passing State holders makes components harder to test. Pass the snapshot value and a callback instead.

    Example: Correct State Pattern

    // ❌ Passing State down
    @Composable
    fun Counter(label: String, count: State<Int>) { /* ... */ }
    
    // ✅ Pass values and events instead
    @Composable
    fun Counter(label: String, count: Int, onCountChange: (Int) -> Unit) { /* ... */ }
  12. Manually test detekt and ktlint samples

    main

    If you want to manually trigger the linting process in the sample projects using the root Gradle wrapper, follow these steps:

    Detekt Sample

    Navigate to the detekt sample directory and run the check task. This should fail due to detected compose-rules violations.

    Ktlint Sample

    Navigate to the ktlint sample directory and run the spotlessCheck task. This should fail with 6 lint errors (one per file) due to detected compose-rules violations.

    # Detekt manual test
    cd samples/detekt-sample
    ../../gradlew check
    
    # Ktlint manual test
    cd samples/ktlint-sample
    ../../gradlew spotlessCheck