Jetpack Compose

Migrating to Jetpack Compose Incrementally

A step-by-step strategy for migrating legacy XML-based UIs to Jetpack Compose without rewriting everything at once. Interoperability patterns, state management bridges, and lessons from migrating 5 production apps.

2024 8 min read

Why Incremental, Not Big Bang

The most common Compose migration mistake is attempting a full rewrite. In my experience leading 5 production migrations, the incremental approach is the only one that works for apps with real users and active development teams. A big-bang rewrite means feature freeze, regression risk, and a painful merge conflict period that can stretch for months.

The incremental approach lets you ship Compose code to production within the first week, gain team experience gradually, and maintain velocity on feature work throughout the migration.

Phase 1: New Screens in Compose

The lowest-risk starting point is building all new screens in Compose while leaving existing screens untouched. This requires setting up Compose dependencies, configuring the theme to match your existing design system, and establishing a pattern for Compose screens to coexist with XML-based Activities and Fragments.

I use a ComposeActivity base class that provides the theme wrapper, error boundary, and analytics hooks that every screen needs. New features are built entirely in Compose from day one, giving the team hands-on experience without risking existing functionality.

Phase 2: Island Migration

Once the team is comfortable with Compose fundamentals, start replacing individual UI components within existing XML screens. ComposeView lets you embed Compose content inside any XML layout. Target high-churn components first — UI elements that change frequently are the ones that benefit most from Compose's declarative model and will generate the most time savings.

Common candidates for island migration:

Phase 3: Navigation Bridge

The trickiest part of incremental migration is navigation. Your app likely uses the Fragment-based Navigation Component, and Compose has its own NavHost. Running both simultaneously requires a bridge.

My approach: keep the Fragment Navigation Component as the top-level router. Each Fragment either contains XML layout or a full-screen ComposeView. This avoids the complexity of running two navigation systems simultaneously and preserves deep link handling, back stack behavior, and transition animations.

Eventually, when enough screens are Compose-only, you migrate the navigation itself — but this should be the last step, not the first.

State Management Across the Boundary

The biggest technical challenge is sharing state between Compose and XML parts of the screen. ViewModels with StateFlow provide the cleanest bridge — both XML views (via lifecycleScope.launch { flow.collect {} }) and Compose (via collectAsStateWithLifecycle()) can observe the same state source.

Avoid creating separate state management for Compose and XML portions of the same screen. One ViewModel, one source of truth, multiple observers.

Lessons from 5 Migrations

ComposeViewState HoistingNavigationTestingMaterialThemeInterop