Android App Development

Chapter 6
State Management and Modern App Architecture

Certificate in Web and Android Development
Android App Development   •   Certificate

State Management and Modern App Architecture

Introduction

A small Android application can often be written as a single activity with a few event handlers. As features increase, however, the application must coordinate user input, network responses, local storage, configuration changes, loading indicators, errors, and navigation. If these responsibilities are mixed together, the result is difficult to test and easy to break.

Modern Android architecture addresses this problem by separating responsibilities and controlling how data moves through the application. This chapter introduces unidirectional data flow (UDF), observable state, ViewModel, repositories, dependency separation, and the Model–View–ViewModel (MVVM) pattern. The emphasis is practical: the goal is to help a certificate-level developer build applications that remain understandable as they grow.

The examples use Kotlin and Jetpack concepts. Names and APIs may evolve, but the architectural principles are broadly applicable.

Learning Objectives

By the end of this chapter, you should be able to:

  • explain why state management is necessary in Android applications;
  • distinguish UI state, domain data, and transient events;
  • describe and implement unidirectional data flow;
  • use a ViewModel to retain screen state across configuration changes;
  • expose observable state using StateFlow or similar mechanisms;
  • explain the roles of a repository and data sources;
  • apply MVVM and dependency separation to a small application;
  • identify common architectural mistakes and improve an existing design;
  • evaluate reliability, testability, security, and maintainability implications.

1. Why State Management Matters

State is information that determines what the application should display or how it should behave at a particular moment. In a weather application, state may include the selected city, current temperature, loading status, and an error message. State changes in response to events such as a button tap, a successful HTTP response, or a change in device orientation.

A useful distinction is between source data and UI state. Source data may be a User object retrieved from a database, whereas UI state describes how that data is currently represented: Loading, Success(user), or Error(message). UI state should be explicit rather than hidden in many independent variables.

Poor state management commonly produces:

  • duplicated data in activities and fragments;
  • network requests that restart unexpectedly after rotation;
  • controls displaying contradictory information;
  • business rules embedded in UI code;
  • memory leaks caused by long-lived references to activities;
  • tests that require launching the entire application.

A maintainable design gives each piece of information a clear owner and a controlled path for change.

2. Architectural Separation

Android applications are commonly divided into layers. The exact number varies, but the following arrangement is practical for small and medium applications.

LayerMain responsibilityTypical contents
UIDisplay state and send user intentionsActivity, Fragment, Compose screen
ViewModelCoordinate screen state and actionsViewModel, state reducer, screen logic
Domain (optional)Reusable business rulesUse cases, validation, domain models
DataObtain and update informationRepository, network service, database
InfrastructureTechnical implementationHTTP client, Room database, preferences

The UI should not need to know whether data came from a REST service, a database, or a cache. The repository hides that decision. Similarly, a ViewModel should not hold an Activity or manipulate views directly. It prepares state; the UI renders that state.

Separation of concerns

Separation of concerns means that each component has a focused responsibility. It does not mean that every class must contain only one method. A component should have a coherent reason to change. For example, changing the HTTP library should normally affect a remote data source, not every screen in the application.

Dependency direction

A desirable dependency direction is:

UI → ViewModel → Repository → Data sources

The arrow indicates that the component on the left uses an abstraction or service on the right. Lower-level details should not control the UI. Dependency injection, constructor parameters, and interfaces make this direction visible and testable.

Layered Android architecture and data flow UIrenders state ViewModelowns screen state Repositoryselects data source User eventstap, refresh, retry UI stateloading, data, error One-way state flow: event → state update → rendering
Layered Android architecture showing unidirectional data flow
Figure: Layered Android architecture showing unidirectional data flow

Figure: Layered architecture with one-way movement of user events and UI state.

3. Unidirectional Data Flow

In unidirectional data flow, data moves in one predictable direction:

  1. The UI displays the current state.
  2. The user or system produces an event.
  3. The ViewModel interprets the event.
  4. The ViewModel requests work from a repository or domain component.
  5. The ViewModel produces a new state.
  6. The UI observes and renders the new state.

The UI does not directly modify the state object that it receives. Instead, it reports an intention such as RefreshClicked or SearchSubmitted. This makes the state transition easier to locate and test.

A simplified model is:

newState = reduce(currentState, event, result)

Here, currentState is the existing screen state, event is a user or system action, result is an optional operation result, and reduce represents the logic that determines the next state. This is a conceptual model rather than a requirement to implement a separate function for every application. It assumes that state changes are explicit and that the same inputs should produce the same outcome, apart from external effects such as network calls.

State versus events

A state describes what is true now and can normally be replayed when a screen is recreated. An event describes something that happened, such as navigation, displaying a one-time message, or opening a dialog. Treating events as permanent state may cause a message to appear again after rotation.

For example:

data class ProfileUiState(
    val isLoading: Boolean = false,
    val name: String = "",
    val errorMessage: String? = null
)

sealed interface ProfileEvent {
    data object RetryClicked : ProfileEvent
    data class NameChanged(val value: String) : ProfileEvent
}

An event is handled by a method such as onEvent(event: ProfileEvent). The UI sends events; it does not change ProfileUiState directly.

4. ViewModel and Lifecycle-Aware State

A ViewModel stores and manages UI-related data in a lifecycle-conscious manner. It survives configuration changes, such as rotation, when the associated screen is recreated. It does not automatically survive process death, so durable information may still require a database, preferences, or saved-state mechanisms.

A ViewModel should:

  • expose state rather than views;
  • launch work in a lifecycle-appropriate scope, commonly viewModelScope;
  • avoid references to Activities, Fragments, Views, or Compose contexts;
  • translate repository results into screen-friendly state;
  • provide a small, clear public interface.

Example using Kotlin StateFlow:

class ProfileViewModel(
    private val repository: ProfileRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(ProfileUiState(isLoading = true))
    val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()

    init { loadProfile() }

    fun loadProfile() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true, errorMessage = null) }
            runCatching { repository.getProfile() }
                .onSuccess { profile ->
                    _uiState.value = ProfileUiState(name = profile.name)
                }
                .onFailure { error ->
                    _uiState.update {
                        it.copy(isLoading = false, errorMessage = error.message ?: "Unable to load profile")
                    }
                }
        }
    }
}

The mutable flow is private. Exposing only StateFlow prevents the UI from assigning arbitrary values. This is an example of encapsulation: the component owns its state and controls valid transitions.

A lifecycle-aware UI collects state only while it is active. In a Fragment, collection should normally use a lifecycle-aware API such as repeatOnLifecycle. In Jetpack Compose, collectAsStateWithLifecycle() is commonly used when available. Lifecycle-aware collection reduces unnecessary work and avoids updating a screen that is no longer visible.

5. Modeling UI State Clearly

A single state model prevents contradictory combinations such as isLoading = true and an old error being displayed as if it were current. For simple screens, a data class is sufficient. For mutually exclusive conditions, a sealed hierarchy is often clearer:

sealed interface ArticlesUiState {
    data object Loading : ArticlesUiState
    data class Success(val articles: List<Article>) : ArticlesUiState
    data class Error(val message: String) : ArticlesUiState
}

This model communicates that the screen is in one primary condition at a time. More complex screens may use a data class with independently meaningful fields, for example when a list can display old data while a refresh is in progress.

State should be:

  • complete: it contains everything required to render the screen;
  • stable: it changes only when meaningful information changes;
  • immutable to consumers: callers receive read-only values;
  • serializable or restorable where necessary: important state can be recreated after process death when appropriate.

Avoid storing derived values unnecessarily. If isEmpty can be computed from articles, duplicating it creates a risk that the two values disagree.

6. Repositories and Data Sources

A repository is an abstraction that provides application data and hides data-source decisions. It may coordinate a remote API, a local database, and a cache. The repository is not merely a pass-through class; it defines what the application needs from data.

interface ProfileRepository {
    suspend fun getProfile(): Profile
}

class DefaultProfileRepository(
    private val api: ProfileApi,
    private val dao: ProfileDao
) : ProfileRepository {
    override suspend fun getProfile(): Profile {
        return try {
            val remote = api.fetchProfile()
            dao.replace(remote)
            remote
        } catch (networkError: Exception) {
            dao.read() ?: throw networkError
        }
    }
}

This illustrative repository attempts remote retrieval, updates local storage, and falls back to cached data. A production implementation would distinguish authentication failures, connectivity failures, malformed responses, and database errors rather than catching every exception identically.

Repositories support:

  • replacement of real services with fakes in tests;
  • consistent caching policy;
  • separation between network models and UI models;
  • centralized error translation;
  • future changes to storage technology.

A repository should not know how a particular button looks. Conversely, a ViewModel should not construct SQL queries or configure an HTTP client.

7. MVVM in Practice

MVVM divides an application into:

  • Model: data and business concepts, including repositories and domain models;
  • View: the visible UI, responsible for rendering and forwarding events;
  • ViewModel: state holder and coordinator between the view and model.

MVVM is a responsibility pattern, not a rigid folder structure. A project can follow MVVM while using XML layouts or Jetpack Compose, coroutines or another asynchronous mechanism, and different storage libraries.

A screen might be organized as follows:

feature/profile/
    ProfileScreen.kt
    ProfileViewModel.kt
    ProfileUiState.kt
    ProfileRepository.kt
    ProfileModels.kt

The View observes state and invokes ViewModel functions:

@Composable
fun ProfileScreen(viewModel: ProfileViewModel) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()

    when {
        state.isLoading -> LoadingIndicator()
        state.errorMessage != null -> ErrorPanel(state.errorMessage, viewModel::loadProfile)
        else -> Text("Hello, ${state.name}")
    }
}

The screen does not call the API or decide how a profile is cached. It renders the state it receives. In XML-based applications, the same principle applies through lifecycle-aware observers such as LiveData or StateFlow collection.

8. Dependency Separation and Injection

A dependency is an object another component needs. Hard-coding dependencies makes testing and replacement difficult:

class ProfileViewModel : ViewModel() {
    private val api = RealProfileApi() // tightly coupled
}

Constructor injection is clearer:

class ProfileViewModel(
    private val repository: ProfileRepository
) : ViewModel()

Production code can provide DefaultProfileRepository; a unit test can provide FakeProfileRepository. A dependency-injection framework such as Hilt can automate object creation, but the underlying principle is independent of the framework.

Use interfaces where they represent a meaningful boundary, not automatically for every class. Excessive abstraction can obscure a simple program. The aim is replaceability at boundaries such as network, storage, clock, authentication, and analytics.

9. Worked Application: A Course Catalogue Screen

Background

Consider an illustrative application for a training institute. The course catalogue screen retrieves courses from a server, displays cached courses when offline, supports retry, and shows a loading indicator.

Problem

An initial implementation performs the HTTP request inside an Activity. When the device rotates, a second request begins. The Activity also stores a nullable list, a loading flag, and an error string. These values can become inconsistent, and the implementation is difficult to test without an emulator.

Analysis

The screen needs one explicit state:

sealed interface CourseUiState {
    data object Loading : CourseUiState
    data class Showing(
        val courses: List<Course>,
        val refreshing: Boolean = false
    ) : CourseUiState
    data class Failed(val message: String) : CourseUiState
}

The repository boundary is:

interface CourseRepository {
    suspend fun courses(): List<Course>
}

The ViewModel coordinates the operation:

class CourseViewModel(
    private val repository: CourseRepository
) : ViewModel() {
    private val _state = MutableStateFlow<CourseUiState>(CourseUiState.Loading)
    val state = _state.asStateFlow()

    init { refresh() }

    fun refresh() {
        viewModelScope.launch {
            val previous = _state.value
            if (previous is CourseUiState.Showing) {
                _state.value = previous.copy(refreshing = true)
            } else {
                _state.value = CourseUiState.Loading
            }
            try {
                _state.value = CourseUiState.Showing(repository.courses())
            } catch (e: IOException) {
                _state.value = CourseUiState.Failed("Check your connection and try again")
            }
        }
    }
}

Outcome

The Activity or Compose screen only observes state, displays the relevant branch, and calls refresh() when the user selects Retry. Rotation recreates the UI while the ViewModel retains its current state and coroutine work. A unit test can supply a fake repository that returns a known list or throws an exception.

Learning points

The design has a single owner for screen state, a clear data boundary, controlled error handling, and no UI reference in the ViewModel. It also distinguishes initial loading from refresh, allowing existing content to remain visible during a refresh.

10. Testing and Quality

Architecture improves testing when behavior is expressed independently of Android views. A ViewModel test can use a fake repository and a test dispatcher. Important cases include:

  • successful retrieval produces Showing state;
  • repository failure produces Failed state;
  • retry invokes the repository again;
  • an existing list remains visible during refresh if that is the intended requirement;
  • cancellation does not update a destroyed screen.

Repositories should be tested separately for cache behavior, response mapping, and error translation. UI tests should verify rendering and user interaction rather than repeat every repository test.

A useful quality question is: “Can this behavior be tested without a network connection, real database, or emulator?” If not, the dependency boundary may be too weak.

11. Challenges and Limitations

Overloaded ViewModels

A ViewModel that validates forms, formats dates, executes database queries, performs navigation, and records analytics becomes a new monolith. Move reusable business rules into domain components or dedicated collaborators.

State explosion

Adding a Boolean for every condition can create impossible combinations. Use sealed states for exclusive conditions and document which fields may coexist.

One-time events

Navigation and brief messages require care. A replaying state stream may repeat them after recreation. A lifecycle-aware event design, navigation state with consumption semantics, or an event-handling library may be appropriate. The exact solution depends on the UI toolkit and application requirements.

Process death

A ViewModel is not permanent storage. If a search query, draft, or selected identifier must survive process death, use SavedStateHandle, persistent storage, or a server-side source as appropriate.

Concurrency

Multiple refresh requests can overlap. Decide whether to disable repeated actions, cancel the previous job, or accept concurrent requests. The correct choice depends on the operation. State transitions should not allow an older response to overwrite newer user intent without deliberate handling.

12. Security, Privacy, and Governance

Architecture does not automatically make an application secure. Repositories should avoid logging passwords, access tokens, personal health information, or unnecessary identifiers. Sensitive data should be transmitted over secure connections and stored using appropriate Android security facilities. Authentication tokens should not be placed in ordinary logs or unprotected preferences.

State models should contain only data required by the screen. Minimizing state reduces accidental exposure through debugging tools, screenshots, analytics, and crash reports. Error messages should help users recover without revealing server internals or confidential information.

Applications processing personal data should follow applicable privacy law and organizational policy. Retention, consent, access control, and deletion requirements influence repository and storage design.

13. Contemporary Developments and Best Practices

Jetpack Compose encourages a declarative approach: the UI is a function of state. This fits UDF naturally, but Compose does not eliminate the need for lifecycle awareness, state ownership, or repository boundaries. XML-based interfaces can use the same architecture.

Recommended practices include:

  1. Define the screen state before writing UI code.
  2. Keep mutable state private and expose read-only state.
  3. Keep UI components mostly concerned with rendering and events.
  4. Use ViewModel for screen-level state and coroutine coordination.
  5. Put data-source selection and caching in repositories.
  6. Inject replaceable dependencies through constructors or a DI framework.
  7. Model loading, success, empty, and error conditions deliberately.
  8. Test state transitions with fakes and deterministic inputs.
  9. Handle cancellation, retries, and process death explicitly.
  10. Review logs and state models for privacy leakage.

Chapter Summary

Modern Android applications are easier to maintain when state has a clear owner and moves predictably. Unidirectional data flow separates events from resulting state: the UI reports intentions, the ViewModel coordinates work, and the UI renders observable state. A ViewModel survives configuration changes but is not a replacement for durable storage. StateFlow, LiveData, and related observable mechanisms allow lifecycle-aware rendering.

MVVM provides a practical division between View, ViewModel, and Model. Repositories hide network, database, and cache details, while dependency injection enables testing and replacement. Explicit state models reduce contradictory UI conditions, and careful treatment of transient events prevents repeated navigation or messages. Finally, maintainable architecture must include testing, concurrency decisions, privacy protection, and appropriate handling of process death.

Key Terms

State: Information describing the application or screen at a particular moment.

UI state: State specifically required to render a user interface.

Event: An action or occurrence, such as a tap, retry request, or response.

Unidirectional data flow: A design in which events and state move through a predictable one-way cycle.

MVVM: Model–View–ViewModel architectural pattern.

View: The UI component that renders state and forwards user actions.

ViewModel: A lifecycle-conscious state holder and coordinator for a screen.

Repository: An abstraction that provides data while hiding data-source details.

Data source: A component that obtains or stores data, such as an API or database.

StateFlow: A Kotlin observable holder representing a current state value.

MutableStateFlow: A state flow whose owner can update its value.

Lifecycle: The changing existence and visibility stages of an Android component.

Dependency: An object required by another object to perform its work.

Dependency injection: Supplying dependencies from outside a class rather than constructing them internally.

Encapsulation: Restricting direct access to internal data and implementation details.

Configuration change: A change such as rotation that may recreate an Android UI component.

Process death: Termination of the application process, after which in-memory ViewModel state is lost.

Cache: Stored data used to reduce retrieval time or support limited offline operation.

Sealed hierarchy: A restricted type structure useful for representing finite state alternatives.

Separation of concerns: Dividing software into components with distinct responsibilities.

Review Questions

Short Answer Questions

  1. What is the difference between UI state and an event?
  2. Why should a ViewModel avoid holding an Activity reference?
  3. What responsibility does a repository provide?
  4. How does constructor injection improve testability?
  5. Why should mutable state usually remain private?

Long Answer Questions

  1. Explain the complete unidirectional data-flow cycle in an Android screen.
  2. Compare a data-class UI state with a sealed UI-state hierarchy and identify when each is useful.
  3. Describe how MVVM separates responsibilities among the View, ViewModel, and Model.
  4. Explain how a repository can combine remote and local data sources without exposing implementation details to the UI.
  5. Discuss why a ViewModel does not solve process-death persistence and describe suitable alternatives.

Analytical / Application Questions

  1. An Activity contains an API client, a mutable list, three loading flags, and navigation logic. Identify at least four architectural problems and propose a revised design.
  2. Design a UI-state model for a document-upload screen that supports initial loading, progress, success, cancellation, and failure. Justify whether you would use a data class, sealed hierarchy, or combination.
  3. A user taps Refresh twice and the slower first request finishes last. Explain how this can corrupt displayed state and propose one strategy to prevent or control the race.

Multiple Choice Questions with Answers

Question 1

Which component should normally own screen-level observable state?

A. The ViewModel

B. The XML layout file

C. The HTTP client

D. The Android manifest

Correct Answer: A. The ViewModel

Question 2

Which sequence best represents unidirectional data flow?

A. UI changes repository data directly, then the ViewModel reads it

B. Event reaches the ViewModel, state is updated, and the UI renders the state

C. Repository modifies views, then the Activity validates the database

D. Database sends commands directly to buttons

Correct Answer: B. Event reaches the ViewModel, state is updated, and the UI renders the state

Question 3

What is a primary benefit of exposing StateFlow rather than MutableStateFlow to the UI?

A. It makes network calls synchronous

B. It prevents the UI from arbitrarily changing owned state

C. It automatically encrypts state

D. It permanently stores state after process death

Correct Answer: B. It prevents the UI from arbitrarily changing owned state

Question 4

Which responsibility best belongs to a repository?

A. Choosing the color of a button

B. Holding an Activity reference

C. Hiding whether data comes from an API, cache, or database

D. Rendering a Compose layout

Correct Answer: C. Hiding whether data comes from an API, cache, or database

Question 5

What event commonly causes a ViewModel to be especially useful?

A. A configuration change such as rotation

B. A compiler installation

C. Changing the application icon in the store

D. Deleting an unused Kotlin import

Correct Answer: A. A configuration change such as rotation

Question 6

Which design most clearly represents mutually exclusive loading outcomes?

A. Many unrelated Boolean flags with no constraints

B. A sealed hierarchy containing Loading, Success, and Error

C. A repository that directly edits TextViews

D. A global variable shared by all screens

Correct Answer: B. A sealed hierarchy containing Loading, Success, and Error

Question 7

Why is constructor injection useful in a ViewModel?

A. It allows a fake repository to be supplied in a unit test

B. It removes the need for any state model

C. It guarantees that a server is online

D. It prevents configuration changes

Correct Answer: A. It allows a fake repository to be supplied in a unit test

Question 8

Which statement about ViewModel state is correct?

A. It always survives process death automatically

B. It should normally contain direct references to views

C. It can survive configuration changes but is not durable storage

D. It can only be used with XML layouts

Correct Answer: C. It can survive configuration changes but is not durable storage

Question 9

In the course catalogue case, why might existing courses remain visible during refresh?

A. To provide a better experience while new data is requested

B. Because repositories cannot return new data

C. Because loading indicators are prohibited

D. To make the Activity responsible for caching

Correct Answer: A. To provide a better experience while new data is requested

Question 10

Which practice best supports privacy in state management?

A. Place access tokens in debug logs

B. Store every server response in UI state permanently

C. Include only data required for rendering and avoid sensitive logging

D. Display internal server errors to all users

Correct Answer: C. Include only data required for rendering and avoid sensitive logging

Android App Development
Chapter 6 of 15