Blog InfosAuthorIoannis AnifantakisPublished23. January 2025Topicsandroid, Android App Development, AndroidDev, Jetpack Compose, State ManagementAuthorIoannis AnifantakisPublished23. January 2025Topicsandroid, Android App Development, AndroidDev, Jetpack Compose, State ManagementFacebookTwitter

Introduction

As Android developers, we often face the challenge of managing state across our applications. Whether it’s user authentication, theme preferences, or app-wide settings, having a reliable way to handle global state is crucial.

In this article, we’ll explore different approaches to global state management in Android with Jetpack Compose, including their pros and cons. We’ll then dive into a solution — the StateHolder Pattern — that has worked well in production apps.

Next, we’ll look at unifying multiple StateHolders into an Application State Store for larger projects, without violating SRP (Single Responsibility Principle), ISP (Interface Segregation Principle), or DIP (Dependency Inversion Principle).

Finally, we’ll discuss an alternative repository approach and round off with a comprehensive exploration of CompositionLocals — how to set them up, update them, and how they can be combined with StateHolders to balance convenience and maintainability.

1. Understanding Global State

Before diving into solutions, let’s define global state and see why it’s important.

Global state refers to data that must be accessible from multiple parts of your application. Common examples include:

  • User authentication status and user profile data
  • User preferences and settings (theme, language, notifications)
  • Network connectivity status
  • Feature flags toggling experimental features
  • App-wide configuration or environment data

The challenge is keeping this state consistent throughout the app while following Android best practices and architectural patterns. Improper handling leads to issues like race conditions, memory leaks, or difficulties with testing and debugging.

Important Clarification

In all the approaches that follow — Singleton, Shared ViewModel, StateHolder, Repository, etc. — we end up pursuing a single instance of something that holds on to mutable state. Even in “StateHolder” or “Repository” solutions, we typically scope them as singletons in our dependency injection graph (or otherwise keep one instance alive for the entire application).

The key differences aren’t whether each approach is a “singleton” in the strictest sense. Rather, it’s how we implement and scope that single instance, how we handle lifecycle concerns (e.g., process death vs. screen rotations), and how we manage testability and dependencies. Fundamentally, though, the core concept remains: we have a single source of truth for the data. The patterns you’ll see below just offer different ways to structure or inject this single instance for clarity, testability, and maintainability.
2. Common Approaches and Their Limitations

This section compares two popular methods: the Singleton Pattern and the Shared ViewModel approach. Both can work, but each has significant caveats.

2.1 The Singleton Pattern

A frequent first attempt is the Singleton pattern, in which a single object holds all global data. A typical example:

object GlobalState {
var user: User? = null
var isDarkMode: Boolean = false

private val _userStateFlow = MutableStateFlow<User?>(null)
val userStateFlow = _userStateFlow.asStateFlow()

fun updateUser(user: User?) {
this.user = user
_userStateFlow.update { user }
}
}

Usage:

// In an Activity
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (GlobalState.user != null) {
// Handle logged-in state
}
}
}

// In a Composable
@Composable
fun UserProfile() {
val user by GlobalState.userStateFlow.collectAsState()
user?.let {
Text("Welcome ${it.name}")
}
}

While straightforward, Singletons pose the following problems:

  1. Thread Safety: Mutable properties can cause race conditions if accessed from multiple threads.
  2. Testing Complexity: It’s harder to mock or reset the singleton between tests.
  3. Initialization Issues: No control over creation order or dependencies.
  4. Memory Management: The singleton lives the entire application lifecycle and may lead to memory leaks if it holds large objects.
2.2 Shared ViewModel Approach

Another approach is using an Android ViewModel shared at the Activity (or navigation graph) level:

class SharedViewModel : ViewModel() {
private val _user = MutableStateFlow<User?>(null)
val user = _user.asStateFlow()

fun updateUser(user: User?) {
_user.update { user }
}
}

class MainActivity : ComponentActivity() {
private val sharedViewModel: SharedViewModel by viewModels()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AppContent(sharedViewModel)
}
}
}

Limitations:

  1. Lifecycle Boundaries: The ViewModel is destroyed if the process is killed, and you need to restore global state manually.
     Note: One partial mitigation is using SavedStateHandle in ViewModels, which can automatically save and restore small amounts of data upon process death. However, it doesn’t fully solve large-scale or multi-Activity global state requirements.
  2. Potential “God Object”: It can grow to contain all app state, becoming hard to maintain.
  3. Navigation Complexity: Sharing a single ViewModel across multiple Activities or deep navigation graphs is complicated.
  4. Testing: A large shared ViewModel can become cumbersome to mock or verify.
3. A Better Solution: The StateHolder Pattern

After exploring the drawbacks of singletons and large shared ViewModels, a more modular solution has emerged: the StateHolder Pattern.

The StateHolder Pattern in Brief
  • Dedicated Classes: Each piece of global data (e.g., user login state, theme, network) has its own holder.
  • Reactive Updates: Each holder uses a StateFlow or similar reactive mechanism.
  • Dependency Injection (DI): The holder is provided as a singleton (or another scope) by a DI framework.
  • Immutability: The state is modeled with Kotlin data class.
  • Atomic Updates: Use update { current -> current.copy(...) } for thread-safe “read-modify-write” flows.
  • Compare to “Mini ViewModels”: Conceptually similar, but these classes aren’t tied to Android’s ViewModel lifecycle. Instead, they live as long as the DI scope allows (e.g., application process).

Example:

// The global state data class
data class UserState(
val isLoggedIn: Boolean = false,
val user: User? = null
)

// The StateHolder to handle that state
interface UserStateHolder {
val userState: StateFlow<UserState>
fun updateUser(user: User)
fun clearUser()
}

class UserStateHolderImpl : UserStateHolder {
private val _userState = MutableStateFlow(UserState())
override val userState: StateFlow<UserState> = _userState

override fun updateUser(user: User) {
// Atomic read-modify-write
_userState.update { current ->
current.copy(isLoggedIn = true, user = user)
}
}

override fun clearUser() {
// Atomically reset
_userState.update { UserState() }
}
}

When integrated with a DI framework, you can treat UserStateHolder as a singleton that persists through screen rotations—similar to how a Singleton persists or how a global “Shared ViewModel” might be reused. The difference is it’s not bound to an Activity’s lifecycle but rather to the application process (or a custom scope you define).

Important Note: Doesn’t Process Death Affect StateHolders, Too?

Yes, if the entire app process is killed by the system, a DI-managed singleton (StateHolder) is also lost. You’ll need to restore data from disk-based solutions (DataStoreSharedPreferences, etc.) on next launch. The main difference is scope:

  • Shared ViewModels are often tied to a single Activity or NavGraph. Navigating across multiple Activities can complicate usage.
  • StateHolder singletons remain alive across all screens as long as the process is running. This is simpler for truly global data.

Neither fully avoids process death — only disk-based solutions can help reload data after a full kill.

4. Why Interfaces? (SOLID and Testability)
Dependency Inversion Principle (DIP)

The D in SOLID (Dependency Inversion Principle) states that high-level modules (like UI layers or feature modules) should not depend on the concrete implementations of lower-level modules. They should depend on abstractions. By defining UserStateHolder (an interface) and letting UserStateHolderImpl handle the internals, your UI code depends only on the interface.

Interface Segregation & Open-Closed

Each domain-specific interface (e.g., IUserStateHolderIThemeStateHolder) is segregated to that domain’s responsibilities, and closed for modification. You’re not forced to put everything in one monolithic “manager” class.

Testability

Faking or mocking a small interface is far simpler than dealing with a large or static global object.

5. Implementation with Koin

In this section, we’ll show how to expose your State Holders using Koin — without detailing how to add Koin dependencies or set it up in Application. We focus on how you’d declare and inject these holders.

  1. Create your State Holder (e.g., UserStateHolder) in a file under :app.
  2. Define a Koin module that provides it as a single:

 

// AppModule.kt
import org.koin.dsl.module

val appModule = module {
single<UserStateHolder> { UserStateHolderImpl() }
// single { ThemeStateHolder() }
// single { NetworkStateHolder() }
}

 

Inject in your Activity or Composable:

// in activity
class MainActivity : ComponentActivity() {
private val userStateHolder: UserStateHolder by inject()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MainScreen(userStateHolder)
}
}
}

// or in composable
@Composable
fun MainScreen(userStateHolder: UserStateHolder) {
val userState by userStateHolder.userState.collectAsState()
if (userState.isLoggedIn) {
Text("Welcome, ${userState.user?.name}")
} else {
Text("Please log in")
}
}

Why doesn’t it get recreated on screen rotation?
A Koin single instance is kept as long as the app process is alive. Rotating the screen destroys and recreates the Activity, but not the singleton object in Koin.

6. Testing Considerations

One of the greatest advantages of the StateHolder Pattern — especially with interfaces — is testability:

  1. Fake Implementations: Provide a fake version of your holder interface.
  2. No Singletons to Reset: If you’re injecting via Koin, you can override modules in test to supply a fake instance.
  3. Isolated Unit Tests: Each holder can be tested in isolation, verifying state changes with Flow testing utilities.

Example:

// Using the interface
class FakeUserStateHolder : UserStateHolder {
private val _state = MutableStateFlow(UserState())
override val userState: StateFlow<UserState> = _state

override fun updateUser(user: User) {
_state.update { current ->
current.copy(isLoggedIn = true, user = user)
}
}

override fun clearUser() {
_state.value = UserState()
}
}

class LoginViewModelTest {
private val fakeHolder = FakeUserStateHolder()
private val viewModel = LoginViewModel(fakeHolder)

@Test
fun testDoLogin() {
viewModel.doLogin()
assertTrue(fakeHolder.userState.value.isLoggedIn)
}
}

Because the LoginViewModel depends on UserStateHolder instead of a concrete UserStateHolderImpl, you can inject FakeUserStateHolder in tests with minimal effort.

7. What About SharedPreferences or DataStore?

It’s common to use SharedPreferences or DataStore to persist user preferences (like dark mode enabled, language selection, etc.) across app launches. While these can store data and, in a sense, serve as a “single source of truth” for persistencethey do not directly represent the in-memory state of your application at runtime:

  1. Persistence vs. In-Memory State: SharedPreferences and DataStore are disk-backed solutions. They save user configurations or preferences in a more permanent way, so you can restore them when the app restarts. But they do not automatically push changes to in-memory consumers unless you set up additional flows or watchers.
  2. Reactive Flows: If you merely read/write preferences without a reactive wrapper, you’re not providing a stream of real-time updates to the UI. In other words, these solutions can tell you what the user’s preference was when you last saved it, but they are not designed to manage ephemeral state used throughout the app’s active lifecycle.
  3. Suggested Hybrid Approach: Often you’ll combine State Holders with persistent storage. For example, if dark mode is enabled in DataStore, your ThemeStateHolder can load that value on app start, store it in a reactive StateFlow, and expose it to the UI. When the user toggles dark mode, the holder updates its StateFlow (for immediate UI change) and writes the new preference to DataStore (for persistence).

Because SharedPreferences or DataStore are focused on disk persistence rather than ephemeral state management, they are not included among our primary solutions for in-memory global state (Singleton, Shared ViewModel, StateHolder). They serve a complementary role, ensuring that certain user preferences or session tokens can be reloaded later.

8. Integrated Solutions: The Application State Store

As our application grows, we often find ourselves creating multiple StateHolder classes to manage different aspects of global state — user authentication, theme preferences, onboarding status, and more.

While each StateHolder effectively manages its specific domain, having these scattered across the codebase can become challengi

Source: View source