Blog Infos
Author
Published
Topics
, , , ,
Published
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 challenging to track and maintain, especially in larger teams. We need a way to organize these global states in a centralized, discoverable location. However, simply merging all state management into a single class would violate the Single Responsibility Principle and create a hard-to-maintain “god object.”

In this section, we’ll explore how to achieve centralized state management through the Application State Store pattern — a solution that provides a unified access point to our StateHolders while preserving clean architecture principles and maintaining separation of concerns.

APPLICATION STATE STORE: Unifying the StateHolders

Before:                          After:
                                   ┌───────────────────────────┐
                                   │  ApplicationStateStore    │
 ┌───────────────┐                 │    ┌───────────────┐      │
 │UserStateHolder│                 │    │UserStateHolder│      │
 └───────────────┘      ═══>       │    └───────────────┘      │
 ┌────────────────┐                │    ┌────────────────┐     │
 │ThemeStateHolder│                │    │ThemeStateHolder│     │
 └────────────────┘                │    └────────────────┘     │
                                   └───────────────────────────┘
(Scattered)                        (Centralized but Separated)
Key Benefits of This Approach:
  • Discoverability: New team members can quickly find all global state in one place
  • Maintainability: Each StateHolder remains focused and independent
  • Flexibility: Teams can inject either individual StateHolders or the complete store
  • Testability: Clean interfaces make it easy to mock and test components
  • Scalability: Adding new global state is as simple as creating a new StateHolder and adding it to the store
Approaching SOLID Application State Store Pattern
  • Collecting Multiple StateHolders
    Rather than a single “god object,” you keep each domain’s logic in its own StateHolder (e.g., UserStateHolderOnboardingStateHolderThemeStateHolder). Each is responsible for that slice of global state—adhering to the Single Responsibility Principle (SRP).
  • Exposing an Aggregator Interface
    You then create an ApplicationStateStore interface that references each domain’s StateHolder, giving you a central injection point:

 

// Domain-specific StateHolder interfaces:
interface UserStateHolder { /* ... */ }
interface OnboardingStateHolder { /* ... */ }
interface ThemeStateHolder { /* ... */ }

// The aggregator interface referencing them:
interface ApplicationStateStore {
    val userStateHolder: UserStateHolder
    val onboardingStateHolder: OnboardingStateHolder
    val themeStateHolder: ThemeStateHolder
}
  • Implementing the App Store
    The corresponding implementation simply aggregates references. Note that it doesn’t replicate domain logic; it just exposes each domain’s StateHolder. This keeps each piece of functionality small and cohesive:
class ApplicationStateStoreImpl(
    override val userStateHolder: UserStateHolder,
    override val onboardingStateHolder: OnboardingStateHolder,
    override val themeStateHolder: ThemeStateHolder
) : ApplicationStateStore

 

8.1 Preserving SRP While Integrating

By letting each domain-specific StateHolder remain independent, you avoid merging every method into one giant class. The aggregator (ApplicationStateStore) has a single responsibility: to provide a consolidated entry point—no extra logic.

8.2 Applying ISP and DIP
  • Interface Segregation Principle (ISP)
    If a feature in your app only needs onboarding logic, it can inject OnboardingStateHolder directly without depending on UserStateHolder or ThemeStateHolder. Each interface is small and relevant.
  • Dependency Inversion Principle (DIP)
    In your DI setup, you bind each XyzStateHolder interface to its concrete XyzStateHolderImpl. The aggregator references interfaces instead of implementations, making it easy to substitute fakes in tests or adapt logic without breaking high-level modules.
8.3 Example in Koin

 

// 1. Individual state holders
val appModule = module {
    single<UserStateHolder> { UserStateHolderImpl() }
    single<OnboardingStateHolder> { OnboardingStateHolderImpl() }
    single<ThemeStateHolder> { ThemeStateHolderImpl() }

    // 2. Aggregator
    single<ApplicationStateStore> {
        ApplicationStateStoreImpl(
            userStateHolder = get(),
            onboardingStateHolder = get(),
            themeStateHolder = get()
        )
    }
}

 

Usage in your MainActivity:

class MainActivity : ComponentActivity() {
    private val appStateStore: ApplicationStateStore by inject()

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

@Composable
fun MainScreen(appStateStore: ApplicationStateStore) {
    val userState by appStateStore.userStateHolder.userState.collectAsState()
    val themeState by appStateStore.themeStateHolder.themeState.collectAsState()

    // ...
}

This ensures:

  1. SRP: Each StateHolder remains small and dedicated to a single domain.
  2. ISP: You can inject only the store you need if you don’t need them all.
  3. DIP: The aggregator depends on interfaces, not concrete classes.
8.4 Avoiding the God Object Trap

The aggregator must remain minimal — just references. If you start putting onboarding logic or theme toggles directly into ApplicationStateStoreImpl, you risk creating a “god object.” Instead, keep all domain logic in its respective StateHolder, so each remains easy to test and maintain.

8.5 Where Should I Put My ApplicationStateStore and StateHolder Classes?

When you’re structuring your project, consider the following:

8.5.1 Single Module Setup
  • StateHolder Classes (e.g., UserStateHolderThemeStateHolder) can live in a dedicated package (like com.example.app.stateholders or com.example.app.globalstate), each in its own file.
  • ApplicationStateStore (and its implementation) can live in the same root package or in a core/common package. The main idea is to keep them discoverable and distinct from feature-specific code.
  • Why “core” or “common” package?
  • It prevents them from depending on specific feature modules.
  • It keeps truly global or cross-feature logic in a place that is easy to locate.

In many single-module apps, a simple folder structure like the following works perfectly. Keep your aggregator (ApplicationStateStore) close to your DI definitions, so it’s straightforward to see how everything wires up.

com.example.myapp
    ├─ state
    │   ├─ UserStateHolder.kt
    │   ├─ ThemeStateHolder.kt
    │   └─ ...
    ├─ di
    │   └─ AppModule.kt
    └─ ApplicationStateStore.kt
8.5.2 Multi-Module Setup

If your app is divided into multiple modules (e.g., :core:featureA:featureB:app), you can distribute the state holders as follows:

  1. Core Module (:core or :common):
  • Put the interfaces for the StateHolders that multiple features need.
  • Put the ApplicationStateStore interface (and maybe its implementation) here if the entire app depends on it.
  1. Feature Modules (:featureA:featureB, etc.):
  • Place feature-specific StateHolder implementations if the logic for that feature is truly self-contained. Alternatively, place these implementations in :core if they’re used widely.
  • If a feature is truly independent (e.g., Onboarding) and doesn’t depend on other features, it can hold its own StateHolder. The aggregator in :core simply references the OnboardingStateHolder interface.

2. App Module (:app):

  • Sets up the DI graph (e.g., Koin modules), pulls in the aggregator from :core, and wires up any feature modules as needed.
  • Provides or creates the final ApplicationStateStoreImpl with references to whichever StateHolder interfaces are needed across features.
Essentially:

Interfaces and the aggregator interface go in a “core” or “common” module.

Implementations can live either in the “core” module (if they’re used widely) or in their respective feature modules (if the state is purely feature-scoped).

The final DI assembly (where everything is hooked up) typically happens in the :app module.

Rationale:
  • By placing the aggregator in :core, you ensure that each feature module can be plugged in without forcing cross-dependencies between features.
  • You keep each feature’s StateHolder logic independent from the rest, preserving the possibility for that feature to evolve or be tested in isolation.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

Jobs

No results found.

9. Alternative Approach to Global State: A Singleton Repository + Reusable Use Cases

If the StateHolder pattern or the Application State Store isn’t the right fit, another option is using a repository to manage these same global states (User data, Theme data, etc.) in a single place. This repository can be a singleton (via DI), and it can expose:

  • Reactive flows (e.g., StateFlow) for user login status or theme preference.
  • Reusable “use cases” or methods for fetching/updating user info, toggling dark mode, etc.

Below is an example of how User and Theme states might look if combined in a single repository.

9.1 Example Repository for User & Theme

 

interface UserAndThemeRepository {
    val userFlow: StateFlow<User?>
    val darkModeFlow: StateFlow<Boolean>

    suspend fun fetchUser()
    suspend fun updateUser(newUser: User)

    // For theme state
    fun toggleDarkMode()
    fun setDarkMode(enabled: Boolean)
}

class UserAndThemeRepositoryImpl(
    private val remoteApi: UserApi,             // hypothetical API for user data
    private val localDataSource: LocalDataStore // could be SharedPreferences or DB
) : UserAndThemeRepository {

    private val _userFlow = MutableStateFlow<User?>(null)
    override val userFlow: StateFlow<User?> get() = _userFlow

    private val _darkModeFlow = MutableStateFlow(false)
    override val darkModeFlow: StateFlow<Boolean> get() = _darkModeFlow

    override suspend fun fetchUser() {
        // Fetch from network or local storage
        val user = remoteApi.getUser()
        _userFlow.value = user
        localDataSource.saveUser(user)
    }

    override suspend fun updateUser(newUser: User) {
        _userFlow.value = newUser
        localDataSource.saveUser(newUser)
    }

    override fun toggleDarkMode() {
        val current = _darkModeFlow.value
        _darkModeFlow.value = !current
        localDataSource.saveDarkMode(!current)
    }

    override fun setDarkMode(enabled: Boolean) {
        _darkModeFlow.value = enabled
        localDataSource.saveDarkMode(enabled)
    }
}

 

By having a single UserAndThemeRepository, we effectively manage both user data and theme preferences under one roof. This might be simpler for some teams, especially if you already rely heavily on the repository + use case architecture.

9.2 Global Injection

We’d define this repository as a singleton in our DI setup (e.g., Koin):

// In a Koin module (e.g. repositoryModule.kt)
val repositoryModule = module {
    single<LocalDataStore> { LocalDataStoreImpl(get()) }  // e.g., SharedPreferences
    single<UserApi> { /* create your retrofit or mock API */ }

    single<UserAndThemeRepository> {
        UserAndThemeRepositoryImpl(get(), get())
    }
}

Usage in your code (e.g., a ViewModel or an Activity):

class MainViewModel(
    private val userAndThemeRepo: UserAndThemeRepository
) : ViewModel() {

    val userFlow = userAndThemeRepo.userFlow
    val darkModeFlow = userAndThemeRepo.darkModeFlow

    fun refreshData() {
        viewModelScope.launch {
            userAndThemeRepo.fetchUser()
        }
    }

    fun switchTheme() {
        userAndThemeRepo.toggleDarkMode()
    }
}

// Koin module for ViewModel
val viewModelModule = module {
    viewModel { MainViewModel(get()) }
}

By injecting UserAndThemeRepository as a single instance, the entire app sees consistent state for user data and theme preferences. Just like with the StateHolder approach, everything remains in-memory until the app process is killed (or you store/reload from disk in localDataSource).

9.3 Single-Module vs. Multi-Module Placement

Single-Module:

  • Place the UserAndThemeRepository interface and implementation in a data/ or repository/ package, along with your localDataSource or API classes.
  • Define the Koin module (e.g., repositoryModule.kt) in a di/ folder.
  • Your UI code (Activities, Composables, ViewModels) just inject UserAndThemeRepository.

Multi-Module:

  • :core (or :domain): Put the UserAndThemeRepository interface here.
  • :data: Put the implementation (UserAndThemeRepositoryImpl), the localDataSource, and any networking code.
  • :app: Wires up Koin, bridging interface and implementation.
  • Feature modules (e.g., :featureA) can inject the interface from :core if they need global user/theme states.
9.4 Pros & Cons of the Repository Approach

Pros:

  • Centralizes user and theme logic in one place.
  • Good if you’re already using a “Repository + Use Cases” pattern for your domain logic.
  • Simple to mock in tests — just swap out the repository with a fake.

Cons:

  • If you start adding more global states (e.g., networking flags, onboarding status, push notification config), your repository can quickly balloon into a “god object.”
  • You might lose some granularity compared to separate domain-specific StateHolders.
10. CompositionLocal: An (Occasionally) Useful Shortcut

Jetpack Compose introduces CompositionLocal, a mechanism that allows you to implicitly pass data down the composition tree without repeatedly passing it as parameters. While convenient for smaller pieces of context-like data (e.g., theme colors or shapes), it’s not generally recommended for large-scale or business-critical global state. Let’s see why, followed by how you could update it if you really want to.

10.1 CompositionLocal Basics

CompositionLocal can be defined at the top level of your code, specifying the type of data it holds. For example:

// For demonstration, let's assume we want a simple counter that's
// globally accessible to any composable in the app.
val LocalGlobalCounter = compositionLocalOf<MutableState<Int>> {
    // Typically you'd throw an error or provide a default:
    error("No default provided for LocalGlobalCounter")
}

Then, in some root-level composable (e.g., setContent), you can provide an initial MutableState instance:

@Composable
fun MyApp() {
    // The 'remember' ensures we create this MutableState only once
    val globalCounter = remember { mutableStateOf(0) }

    CompositionLocalProvider(
        LocalGlobalCounter provides globalCounter
    ) {
        // The rest of the app (navigation, screens, etc.)
        MyNavHost()
    }
}

Now any child composable below MyApp in the composition tree can retrieve and modify LocalGlobalCounter:

@Composable
fun MainScreen() {
    val counterState = LocalGlobalCounter.current

    Column {
        Text("Global Counter: ${counterState.value}")
        Button(onClick = { counterState.value++ }) {
            Text("Increment Counter")
        }
    }
}

Here, tapping “Increment Counter” updates counterState.value, which is shared by all composables that read LocalGlobalCounter. Any composable referencing this same LocalGlobalCounter.current will automatically recompose to show the new value.

10.2 Updating CompositionLocal from “Anywhere”

Because we provided a mutable state object (MutableState<Int>) in the CompositionLocal, you can “write” to that state from any composable that has access to LocalGlobalCounter.current. For instance, if you have multiple screens:

@Composable
fun AnotherScreen() {
    val counterState = LocalGlobalCounter.current
    Text("AnotherScreen sees the value: ${counterState.value}")

    Button(onClick = { counterState.value += 10 }) {
        Text("Add 10")
    }
}

This immediately affects the same counterState.value as MainScreen sees, because they’re both reading the same MutableState<Int> object stored in the CompositionLocal.

Important: If you truly want to update a single “source of truth” from other layers (like a ViewModel or a business logic class), you would need to pass that MutableState (or a reference to it) into those classes or rely on more robust solutions (StateHolder, Repository). CompositionLocal alone is purely a UI-level mechanism.

10.3 Why It’s Not Recommended for Serious “Global” State

Although you can do the above, it has downsides if you rely on it heavily:

  1. Couples the State to the UI Composition Tree
    Your global data is now bound to the Compose runtime. Non-UI layers can’t easily read or modify it.
  2. Test Complexity
    CompositionLocals are less straightforward to mock or override than a simple interface. You need to set up custom providers in your test harness or remember to call CompositionLocalProvider in test composables.
  3. Implicit Dependencies
    Any composable in the tree can read or write the state. This can make data flows unclear in larger projects.
  4. Process Death & Persistence
    The app’s entire composition is torn down if the process is killed. You’d need a separate disk-based solution to persist or restore the data.

Therefore, for robust apps (especially with multi-module architecture or complex data needs), it’s generally better to use StateHolders or Repositories as your global data solution. You can then optionally read those values in composables — even providing them as CompositionLocals if you want to skip parameter passing. But you’d still keep the actual “writes” and “business logic” in a lifecycle-independent class.

10.4 Example Combining with a StateHolder

If you still want to combine the two approaches, you can keep your domain logic in a UserStateHolder (with methods like login() or setDarkMode()), then observe that data in your root composable, exposing it via CompositionLocal for children to read. For example:

// 1) Define locals (notice these are not mutable states themselves):
val LocalUserData = compositionLocalOf<User?> { null }
val LocalIsDarkMode = compositionLocalOf<Boolean> { false }

@Composable
fun MyApp(userStateHolder: UserStateHolder) {
    // 2) Observe from the holder
    val userState by userStateHolder.userState.collectAsState()
    val isDarkMode by userStateHolder.darkModeFlow.collectAsState()

    // 3) Provide them as read-only values
    CompositionLocalProvider(
        LocalUserData provides userState.user,
        LocalIsDarkMode provides isDarkMode
    ) {
        MyNavHost()
    }
}

When the user logs in or toggles dark mode, you call methods on userStateHolder. That triggers the flows to emit new values, causing MyApp to recompose and re-provide updated CompositionLocals to all child composables.

  • Child composables can read LocalUserData.current or LocalIsDarkMode.current.
  • Updates happen through the StateHolder (or repository) instead of a direct composition local assignment.

This method preserves better testability and separation of concerns, while still letting you avoid “parameter drilling” through multiple composables.

10.5 Recommendation
  1. Use a Mutable CompositionLocal if you want a trivial, purely UI-bound global variable (like a small ephemeral counter or debugging flag).
  2. For serious app-wide state (e.g., user sessions, theme toggles, or domain objects), rely on StateHolders or Repositories that are not tied to the Compose lifecycle. If you want to share them “everywhere,” you can still provide them in a CompositionLocal, but keep the write logic in your holder or repository.

In summary, you can update CompositionLocals anywhere in the app by providing a mutable state object (like MutableState) at the root level, but it’s rarely the ideal solution for large, testable, multi-layer architectures. Instead, treat it as a UI convenience for smaller or demo scenarios, or use CompositionLocals to read an underlying global state from a domain-specific holder or repository.

10.6 Why Combine Composition Locals with StateHolders?

Despite the limitations, combining these can be a practical solution:

  • Centralized Data: The StateHolder (or repository) remains the authoritative source for user data, theme settings, and other business-critical states.
  • UI Convenience: The CompositionLocal means you can read that state from any composable without explicitly passing parameters down multiple levels.
  • Solid Test Boundaries: Real logic (e.g., login()toggleDarkMode()) lives in the StateHolder. That’s easy to mock or replace in tests. The CompositionLocal is mostly for observing those changes in the UI.

Hence, in many apps — especially those with multi-layer architecture — this hybrid pattern is quite practical:

  1. StateHolder manages data outside the UI lifecycle.
  2. The top-level composable collects the StateFlow and provides it via CompositionLocal.
  3. Child composables read from the CompositionLocal, recompose automatically, and call StateHolder methods when they need to update data.
10.7 Composition Locals with StateHolders is Recommended?

Yes, using CompositionLocals + StateHolders is a valid and often recommended approach for the UI layer:

  • StateHolder: Owns the data, handles updates, can be tested in isolation.
  • CompositionLocal: Allows any composable to read that data without explicitly passing parameters.

You’ll still want to ensure that all business logic (authentication, toggling flags, data persistence) remains in the StateHolder or Repository. That way, you keep your design testable, modular, and consistent with best practices.

In short: If you like the convenience of CompositionLocals to read global data in composables — while the actual data and update logic live in a StateHolder — go for it. It’s a solid middle ground, balancing ease of UI development with an architecture that remains maintainable, testable, and well-scoped.

Conclusion

Global state management is crucial for delivering a robust Android experience with Jetpack Compose. Singletons and Shared ViewModels might suffice for small apps but can become unwieldy. The StateHolder Pattern offers a more modular, testable approach — especially when combined with persistence solutions like DataStore or SharedPreferences.

For larger or more complex apps, an Application State Store can unify multiple StateHolders behind one injection point. By collecting domain-specific StateHolders (SRP), exposing a minimal aggregator interface (ISP + DIP), and keeping each domain holder separate, you avoid the “god object” trap while maintaining a clean, reactive architecture.

Where to put them?

In single-module apps, keep them in a core or common package.

In multi-module apps, place the aggregator interface (and shared logic) in :core, while feature-specific StateHolders can live in their own feature modules if needed.

repository-based approach can serve as an alternative. By merging User and Theme states into a single repository — with flows for user data and theme preferences — you can still achieve clarity, maintainability, and testability. Whether you pick StateHolders or a singleton repository, the key is to structure your global data so it’s simple to reason about, easy to test, and flexible for growth.

Lastly, CompositionLocals can be used to simplify UI code by providing or “scoping” global data, but they should generally be reserved for small, UI-centric concerns or combined with a StateHolder for more serious global state. This way, your architecture remains robust, testable, and well-defined across all layers of your Android app.

This article is previously published on proandroiddev.com.

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE
Menu