Understanding Shared ViewModels in Android: A Comprehensive Guide

Table of Contents

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:

  1. Lifecycle Safety: Data stored in a ViewModel persists through configuration changes like screen rotations, avoiding unwanted resets.
  2. Simplified Communication: Fragments don’t need to interact directly, reducing the risk of complex dependencies and bugs.
  3. Consistent Data: A single source of truth ensures data integrity and synchronization across multiple components.
  4. 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):

Kotlin
// 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:

Kotlin
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.

Kotlin
// 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) } }
        }
    }
}

SavedStateHandle survives process death when used with Navigation and lets you read nav arguments via savedStateHandle.get<T>("arg") or create StateFlows: savedStateHandle.getStateFlow("key", default).

Scoping Options (Activity vs. Nav Graph)

Activity scope — share across all fragments in the same activity:

Kotlin
private val vm: ProfileSharedViewModel by activityViewModels()
  • Lives as long as the Activity is 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:

Kotlin
private val vm: ProfileSharedViewModel by navGraphViewModels(R.id.profile_graph)
  • One instance per NavBackStackEntry for 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:

Kotlin
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.

Kotlin
@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:

Kotlin
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:

Kotlin
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:

Kotlin
// 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:

Kotlin
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:

Kotlin
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:

Kotlin
@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 MainDispatcherRule by swapping Dispatchers.Main with a StandardTestDispatcher. Keep repositories pure and synchronous in tests, or use runTest with 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 viewLifecycleOwner when observing in fragments (not this), 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 StateFlow for 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/Channel for events, not StateFlow/LiveData.
  • ViewModel not shared between fragments: Ensure both fragments use the same scope (activityViewModels() or the same navGraphId).
  • ViewModel survives too long: You probably used activity scope where a nav-graph scope made more sense.
  • Collectors keep running off-screen: Wrap collect in repeatOnLifecycle(Lifecycle.State.STARTED).

Minimal, End-to-End Example

Navigation graph (excerpt):

XML
<!-- 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:

Kotlin
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.

Skill Up: Software & AI Updates!

Receive our latest insights and updates directly to your inbox

Related Posts

error: Content is protected !!