In modern Android development, ViewModel has become an indispensable component for managing UI-related data in a lifecycle-conscious manner. One powerful application of ViewModels is sharing data between multiple fragments or activities. This guide provides a deep dive into shared ViewModels, explaining their purpose, implementation, and best practices for creating seamless data sharing in your Android apps.
The Concept of Shared ViewModels
A Shared ViewModel is a ViewModel instance that is accessible across multiple fragments or activities, enabling shared state management. This approach is ideal when:
- Fragment Communication: Multiple fragments need to work with the same data, such as a user profile or settings.
- Decoupling Logic: You want fragments to exchange information without creating brittle, tightly-coupled dependencies.
- Navigation Component Scenarios: Sharing data across destinations within a navigation graph requires clean state management.
Unlike standalone ViewModels scoped to a single UI component, shared ViewModels can be scoped to an entire activity or a specific navigation graph, allowing seamless state sharing while respecting lifecycle boundaries.
Why Use Shared ViewModels?
Here are some compelling reasons to choose shared ViewModels:
- Lifecycle Safety: Data stored in a ViewModel persists through configuration changes like screen rotations, avoiding unwanted resets.
- Simplified Communication: Fragments don’t need to interact directly, reducing the risk of complex dependencies and bugs.
- Consistent Data: A single source of truth ensures data integrity and synchronization across multiple components.
- Modern Architecture: Shared ViewModels align perfectly with MVVM (Model-View-ViewModel) architecture, a best practice for building scalable Android apps.
Step-by-Step Implementation of Shared ViewModels
Setting Up Dependencies
Add the core libraries you’ll need (use the latest stable versions from AndroidX/Hilt):
// app/build.gradle.kts
dependencies {
// Fragments & Activity KTX
implementation("androidx.fragment:fragment-ktx:<ver>")
implementation("androidx.activity:activity-ktx:<ver>")
// Lifecycle / ViewModel / coroutines support
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:<ver>")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:<ver>")
// Only if you still use LiveData:
implementation("androidx.lifecycle:lifecycle-livedata-ktx:<ver>")
// Jetpack Navigation (Fragment)
implementation("androidx.navigation:navigation-fragment-ktx:<ver>")
implementation("androidx.navigation:navigation-ui-ktx:<ver>")
// (Optional) Hilt for DI + ViewModels
implementation("com.google.dagger:hilt-android:<ver>")
kapt("com.google.dagger:hilt-android-compiler:<ver>")
implementation("androidx.hilt:hilt-navigation-fragment:<ver>")
kapt("androidx.hilt:hilt-compiler:<ver>")
}If using Hilt, also apply the plugin in your module’s Gradle file:
plugins {
id("com.google.dagger.hilt.android")
kotlin("kapt")
}Designing the Shared ViewModel
Prefer a single source of UI state with immutable data classes and expose it via StateFlow. Keep side effects (like toasts or navigation) separate using a SharedFlow for one-off events.
// Shared ViewModel example (Kotlin)
@HiltViewModel // Remove if you’re not using Hilt
class ProfileSharedViewModel @Inject constructor(
private val repo: ProfileRepository, // Your data source
private val savedStateHandle: SavedStateHandle // For process death & args
) : ViewModel() {
data class UiState(
val user: User? = null,
val isLoading: Boolean = false,
val error: String? = null
)
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState
// One-off events (navigation, snackbar, etc.)
private val _events = MutableSharedFlow<Event>()
val events: SharedFlow<Event> = _events
sealed interface Event { object Saved : Event }
fun load(userId: String) {
// Example of persisting inputs using SavedStateHandle
savedStateHandle["lastUserId"] = userId
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
runCatching { repo.fetchUser(userId) }
.onSuccess { user -> _uiState.update { it.copy(user = user, isLoading = false) } }
.onFailure { e -> _uiState.update { it.copy(isLoading = false, error = e.message) } }
}
}
fun updateName(newName: String) {
_uiState.update { state ->
state.copy(user = state.user?.copy(name = newName))
}
}
fun save() {
val current = _uiState.value.user ?: return
viewModelScope.launch {
runCatching { repo.saveUser(current) }
.onSuccess { _events.emit(Event.Saved) }
.onFailure { e -> _uiState.update { it.copy(error = e.message) } }
}
}
}
SavedStateHandlesurvives process death when used with Navigation and lets you read nav arguments viasavedStateHandle.get<T>("arg")or createStateFlows:savedStateHandle.getStateFlow("key", default).
Scoping Options (Activity vs. Nav Graph)
Activity scope — share across all fragments in the same activity:
private val vm: ProfileSharedViewModel by activityViewModels()- Lives as long as the
Activityis alive (across configuration changes). - Good for app-wide state within that activity (e.g., cart, session, toolbar state).
Navigation graph scope — share only within a specific flow:
private val vm: ProfileSharedViewModel by navGraphViewModels(R.id.profile_graph)- One instance per
NavBackStackEntryfor that graph. - Cleared when that graph is popped off the back stack.
- Great for multi-step wizards (e.g., signup → verify → done).
Using Hilt? Get a Hilt-injected, nav-graph–scoped VM with:
private val vm: ProfileSharedViewModel by hiltNavGraphViewModels(R.id.profile_graph)Avoid sharing a ViewModel across different activities. Use a repository/single source of truth instead, or adopt a single-activity architecture.
Using the Shared ViewModel in Fragments
Collect state with lifecycle awareness. Use repeatOnLifecycle so collection stops when the view is not visible.
@AndroidEntryPoint // if using Hilt
class EditProfileFragment : Fragment(R.layout.fragment_edit_profile) {
private val vm: ProfileSharedViewModel by activityViewModels() // or navGraphViewModels(...)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// State
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
vm.uiState.collect { state ->
// update text fields, progress bars, errors
}
}
}
// One-off events
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
vm.events.collect { event ->
when (event) {
is ProfileSharedViewModel.Event.Saved -> {
// e.g., findNavController().navigateUp()
}
}
}
}
}
// Example inputs
val save = view.findViewById<Button>(R.id.saveButton)
save.setOnClickListener { vm.save() }
}
}And another fragment in the same scope sees the same instance:
class PreviewProfileFragment : Fragment(R.layout.fragment_preview_profile) {
private val vm: ProfileSharedViewModel by activityViewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
vm.uiState.collect { state ->
// render preview using state.user
}
}
}
}
}If you prefer LiveData:
vm.liveData.observe(viewLifecycleOwner) { state -> /* ... */ }Passing Arguments & Using SavedStateHandle
When you navigate with arguments, Navigation stores them in the destination’s SavedStateHandle, which the ViewModel can read:
// In the ViewModel (constructor already has savedStateHandle)
private val userId: String? = savedStateHandle["userId"]
init {
userId?.let(::load)
}You can also write to the handle to restore after process death:
savedStateHandle["draftName"] = "Amol"
val draftNameFlow = savedStateHandle.getStateFlow("draftName", "")Handling One-Off Events Correctly
Never mix events with state (or they re-trigger on rotation). Use SharedFlow (or Channel) for fire-and-forget actions:
private val _events = MutableSharedFlow<Event>(extraBufferCapacity = 1)
val events = _events.asSharedFlow()
// Emit: _events.tryEmit(Event.Saved)Testing a Shared ViewModel
Use the coroutine test utilities and a fake repository:
@OptIn(ExperimentalCoroutinesApi::class)
class ProfileSharedViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule() // sets Dispatchers.Main to a TestDispatcher
private val repo = FakeProfileRepository()
private lateinit var vm: ProfileSharedViewModel
@Before
fun setUp() {
vm = ProfileSharedViewModel(repo, SavedStateHandle())
}
@Test
fun `load populates user and clears loading`() = runTest {
vm.load("42")
val state = vm.uiState.first { !it.isLoading }
assertEquals("42", state.user?.id)
assertNull(state.error)
}
}Implement
MainDispatcherRuleby swappingDispatchers.Mainwith aStandardTestDispatcher. Keep repositories pure and synchronous in tests, or userunTestwith fakes.
When to Choose Each Scope
Use Activity scope when:
- Tabs/bottom navigation fragments need the same state.
- Data lives for the whole activity session (e.g., cart, auth session).
Use Nav-graph scope when:
- Data is local to a flow (onboarding, multi-step form).
- You want the ViewModel cleared when the flow finishes (pop).
Best Practices
- Expose immutable state (
StateFlow,LiveData) and keep mutables private. - Don’t hold views/context inside ViewModels. Inject repositories/use cases instead.
- Use
viewLifecycleOwnerwhen observing in fragments (notthis), to avoid leaks. - Keep UI state small & serializable if you rely on
SavedStateHandle. - Model errors in state and display them; don’t throw them up to the UI.
- Avoid shared ViewModels across activities; share via repository or a data layer.
- Prefer
StateFlowfor new code; LiveData is still fine if your app already uses it.
Common Pitfalls (and Fixes)
- State replays on rotation (toast fires again): Use
SharedFlow/Channelfor events, notStateFlow/LiveData. - ViewModel not shared between fragments: Ensure both fragments use the same scope (
activityViewModels()or the samenavGraphId). - ViewModel survives too long: You probably used activity scope where a nav-graph scope made more sense.
- Collectors keep running off-screen: Wrap
collectinrepeatOnLifecycle(Lifecycle.State.STARTED).
Minimal, End-to-End Example
Navigation graph (excerpt):
<!-- res/navigation/profile_graph.xml -->
<navigation
android:id="@+id/profile_graph"
app:startDestination="@id/editProfileFragment">
<fragment
android:id="@+id/editProfileFragment"
android:name="com.example.EditProfileFragment">
<action
android:id="@+id/action_edit_to_preview"
app:destination="@id/previewProfileFragment" />
<argument
android:name="userId"
app:argType="string" />
</fragment>
<fragment
android:id="@+id/previewProfileFragment"
android:name="com.example.PreviewProfileFragment" />
</navigation>Fragments sharing the same ViewModel via nav graph:
class EditProfileFragment : Fragment(R.layout.fragment_edit_profile) {
private val vm: ProfileSharedViewModel by navGraphViewModels(R.id.profile_graph)
// collect uiState/events as shown earlier…
}
class PreviewProfileFragment : Fragment(R.layout.fragment_preview_profile) {
private val vm: ProfileSharedViewModel by navGraphViewModels(R.id.profile_graph)
// collect uiState and render preview…
}Conclusion
Shared ViewModels let fragments share state safely without talking to each other directly. Scope them to the activity for app-wide state or to a navigation graph for flow-scoped state. Expose state with StateFlow, drive UI with lifecycle-aware collectors, use SavedStateHandle for resilience, and keep one-off events separate. Follow these patterns and you’ll get predictable, testable, and decoupled UI flows.
