Jetpack Compose Cursor Rules: Modern Android UI

Cursor rules for Jetpack Compose covering composable functions, state management, Material 3, Navigation, ViewModel, recomposition, and preview testing.

June 8, 2025by PromptGenius Team
jetpack-composeandroidkotlincursor-rulesmobileui
Jetpack Compose Cursor Rules: Modern Android UI

Overview

Jetpack Compose is Android's modern declarative UI toolkit, replacing XML-based layouts with Kotlin composable functions. These cursor rules enforce unidirectional data flow, state hoisting, ViewModel integration, Material 3 theming, Navigation Compose patterns, and preview-driven development to help AI assistants generate idiomatic, recomposition-aware Android UI code.

Note:

Enforces declarative composable patterns, state/StateFlow management, ViewModel + SavedStateHandle, Material 3 Design tokens, Navigation Compose with type-safe routes, modifier ordering, and @Preview testing conventions.

Rules Configuration

---
description: Enforces Jetpack Compose best practices including unidirectional data flow, state hoisting, ViewModel integration, Material 3 theming, Navigation Compose, modifier conventions, and preview testing. Provides guidelines for modern declarative Android UI development.
globs: **/*.kt
---
# Jetpack Compose Best Practices

You are an expert in Jetpack Compose, Android UI development, and Kotlin.
You understand declarative UI, unidirectional data flow, recomposition optimization, and Material Design 3.

### Project Structure
- /ui/screens — top-level composable screens, one per destination
- /ui/components — reusable composable UI components (buttons, cards, inputs)
- /ui/theme — Material 3 theme, colors, typography, shapes
- /ui/navigation — NavHost setup, route definitions, navigation actions
- /data — repositories, data sources, models
- /viewmodel — ViewModel classes holding UI state as StateFlow
- /util — extension functions, composable helpers, preview data

### Composable Functions & State
- Hoist state to the highest common ancestor that needs it
- Pass state down via parameters, events up via lambdas
- Use remember { mutableStateOf() } for local ephemeral state only
- Derive UI state from a single source of truth in the ViewModel
- Expose state as StateFlow, collect with collectAsStateWithLifecycle()
- Never mutate state during composition — use LaunchedEffect or SideEffect

### ViewModel & Lifecycle
- Extend ViewModel(); inject repositories via constructor
- Expose UI state as a single data class: data class PostsUiState(val posts: List<Post>, val isLoading: Boolean)
- Use viewModel() or hiltViewModel() to obtain ViewModel in composables
- Use SavedStateHandle for surviving process death
- Scope coroutines to viewModelScope; cancel automatically on clear

### Material 3 Theming
- Define MaterialTheme with custom colorScheme, typography, shapes
- Use MaterialTheme.colorScheme.primary (never hardcode colors)
- Support dynamic colors with dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
- Use Surface, Scaffold, TopAppBar, NavigationBar for layout structure
- Apply Modifier.semantics for accessibility

### Navigation
- Use NavHost with composable() route builder and type-safe navigation
- Pass arguments via route placeholders: composable("post/{postId}")
- Never pass complex objects as nav arguments — pass IDs, load data in ViewModel
- Avoid nested NavHosts; use a single NavHost with nested navigation graphs
- Navigate from ViewModel via savedStateHandle or exposed navigation lambdas

### Modifiers & Layout
- Order modifiers correctly: size → padding → background → clickable
- Use Modifier.fillMaxWidth(), .weight(), .padding() for responsive layouts
- Use LazyColumn/LazyRow for scrollable lists; provide stable keys
- Use AnimatedVisibility and animateContentSize for transitions
- Extract complex modifier chains into extension functions

### Performance & Recomposition
- Mark composable functions as restartable and skippable by providing stable params
- Use derivedStateOf for expensive derived calculations
- Avoid unstable parameters — use data classes or @Stable annotation
- Keep composable functions small and focused; split large screens into components
- Use @ReadOnlyComposable for composables that don't emit into composition

### Testing & Previews
- Annotate composables with @Preview for rapid iteration
- Provide PreviewParameterProvider for stateful previews
- Write Compose UI tests with createComposeRule() and onNodeWithText()
- Test ViewModels with Turbine for StateFlow assertions
- Use fake repositories for ViewModel unit tests

Installation

Create jetpack-compose.mdc in your project's .cursor/rules/ directory and paste the configuration above. Cursor and Windsurf both read .cursor/rules/ — Copilot users place it in .github/copilot-instructions.md instead.

Examples

// ui/screens/PostsScreen.kt
@Composable
fun PostsScreen(
    viewModel: PostsViewModel = hiltViewModel(),
    onPostClick: (Long) -> Unit
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    PostsScreenContent(
        uiState = uiState,
        onPostClick = onPostClick,
        onRefresh = viewModel::refresh
    )
}

@Composable
private fun PostsScreenContent(
    uiState: PostsUiState,
    onPostClick: (Long) -> Unit,
    onRefresh: () -> Unit
) {
    Scaffold(
        topBar = {
            TopAppBar(title = { Text("Posts") })
        }
    ) { padding ->
        when {
            uiState.isLoading -> Box(
                modifier = Modifier.fillMaxSize().padding(padding),
                contentAlignment = Alignment.Center
            ) {
                CircularProgressIndicator()
            }
            uiState.error != null -> ErrorState(
                message = uiState.error,
                onRetry = onRefresh
            )
            else -> LazyColumn(
                modifier = Modifier.fillMaxSize().padding(padding)
            ) {
                items(
                    items = uiState.posts,
                    key = { it.id }
                ) { post ->
                    PostCard(
                        post = post,
                        onClick = { onPostClick(post.id) }
                    )
                }
            }
        }
    }
}

@Composable
private fun PostCard(post: Post, onClick: () -> Unit) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp, vertical = 8.dp)
            .clickable(onClick = onClick),
        colors = CardDefaults.cardColors(
            containerColor = MaterialTheme.colorScheme.surfaceVariant
        )
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Text(
                text = post.title,
                style = MaterialTheme.typography.titleMedium
            )
            Spacer(modifier = Modifier.height(4.dp))
            Text(
                text = post.body,
                maxLines = 3,
                overflow = TextOverflow.Ellipsis,
                style = MaterialTheme.typography.bodyMedium
            )
        }
    }
}
// viewmodel/PostsViewModel.kt
@HiltViewModel
class PostsViewModel @Inject constructor(
    private val postRepository: PostRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(PostsUiState())
    val uiState: StateFlow<PostsUiState> = _uiState.asStateFlow()

    init {
        loadPosts()
    }

    fun refresh() {
        _uiState.update { it.copy(isLoading = true, error = null) }
        loadPosts()
    }

    private fun loadPosts() {
        viewModelScope.launch {
            postRepository.getPosts()
                .onSuccess { posts ->
                    _uiState.update { it.copy(posts = posts, isLoading = false) }
                }
                .onFailure { e ->
                    _uiState.update {
                        it.copy(error = e.message ?: "Unknown error", isLoading = false)
                    }
                }
        }
    }
}

data class PostsUiState(
    val posts: List<Post> = emptyList(),
    val isLoading: Boolean = false,
    val error: String? = null
)
// ui/navigation/NavGraph.kt
@Composable
fun AppNavGraph(navController: NavHostController = rememberNavController()) {
    NavHost(
        navController = navController,
        startDestination = "posts"
    ) {
        composable("posts") {
            PostsScreen(onPostClick = { postId ->
                navController.navigate("post/$postId")
            })
        }
        composable(
            route = "post/{postId}",
            arguments = listOf(navArgument("postId") { type = NavType.LongType })
        ) { backStackEntry ->
            val postId = backStackEntry.arguments?.getLong("postId") ?: return@composable
            PostDetailScreen(postId = postId)
        }
    }
}

@Composable
private fun ErrorState(message: String?, onRetry: () -> Unit) {
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(
            text = message ?: "Something went wrong",
            color = MaterialTheme.colorScheme.error
        )
        Spacer(modifier = Modifier.height(16.dp))
        OutlinedButton(onClick = onRetry) {
            Text("Retry")
        }
    }
}

@Composable
fun PostDetailScreen(postId: Long) {
    Text("Post detail: $postId")
    // Display full post content, comments, etc.
}