Mobile Development · Guide

State & Architecture

MVVM and MVI, dependency injection, and doing work that outlives the screen that started it.

— min read Mobile Development

One Source of Truth

Almost every hard mobile bug is a state bug: two places holding the same fact and disagreeing, or state living in a screen the system just destroyed.

Architecture on mobile exists to answer two questions. Where does state live so that it survives rotation, backgrounding and process death? And who is allowed to change it, so that a value cannot be edited from four places on three threads?

The layering that has settled across both platforms is unremarkable and works: UI renders state and sends events, a ViewModel holds state and business decisions, a repository owns data and decides between cache and network, and data sources know only about SQL or HTTP.

MVVM & MVI

MVVM puts a ViewModel between screen and data. It exposes observable state, receives events, and — critically on Android — outlives configuration changes, so rotating the device does not refetch anything.

MVI tightens the same idea into one loop: a single immutable state object, intents as the only way in, and a reducer producing the next state. Every change has one path, which makes it replayable and easy to test.

MVVMMVI
StateSeveral observable fieldsOne immutable object
InputMethod callsTyped intents
StrengthLighter, familiarImpossible states are unrepresentable
CostFields can drift out of syncMore ceremony for a simple screen
The bug MVI is designed to remove: separate isLoading, data and error fields, which allow "loading and failed and showing data" — a state the UI was never designed for. One sealed state type makes it unrepresentable.

Whichever you pick, keep data flowing one way: events up, state down. Two-way binding feels convenient for a week and then nobody can say what changed a value.

Dependency Injection

A ViewModel that constructs its own repository, which constructs its own HTTP client, cannot be tested without a network. Dependency injection just means things are handed what they need instead of building it — the seam that lets a test pass a fake.

// Handed in, not constructed: the test passes a fake repository
class ProfileViewModel(private val repo: UserRepository) : ViewModel() {
    val state = repo.observeUser().stateIn(viewModelScope, Lazily, Loading)
}

Frameworks — Hilt or Koin on Android, initialiser injection or a small container on iOS — automate the wiring. They are convenience, not the point; the point is that dependencies are declared, replaceable, and scoped to the right lifetime.

Scope matters more than the framework. A dependency scoped to the whole application when it should have been scoped to a screen is a leak — it keeps that screen's data alive for the life of the process.

Async & Background Work

The main thread draws the UI. Every frame has about 16ms, so anything slower — disk, network, parsing, image work — must happen elsewhere or the app visibly stutters.

The structured-concurrency model on both platforms ties a task to a scope that can cancel it. That is what makes cancellation correct by default: leave the screen and its scope dies, taking the in-flight request with it, so nothing writes to a view that no longer exists.

WorkWhere it belongs
Tied to a visible screenThe screen's scope — cancelled when it goes away
Must finish even if the user leavesA scheduled background worker, not a coroutine
Periodic syncThe OS scheduler, which batches it for battery
Long uploadA foreground service or background upload API
Background execution is not yours to decide. Both systems defer, batch and kill background work to protect battery — an app that assumes it will run every fifteen minutes will not.

Interview Questions

Why does a ViewModel outlive the screen?

Configuration changes destroy and recreate the screen. State held in a ViewModel survives that, so rotating a device does not refetch data or lose the user's place.

What does MVI add over MVVM?

A single immutable state object and one path to change it, which makes invalid combinations — loading and error and data at once — unrepresentable, and makes changes replayable in tests.

Why prefer unidirectional data flow?

With events up and state down there is one place a value can change. Two-way binding spreads mutation across the UI until no one can say what caused an update.

What problem does dependency injection actually solve?

It creates a seam. If a component is handed its collaborators rather than constructing them, tests can substitute fakes and lifetimes can be scoped deliberately.

When is a coroutine the wrong tool?

When the work must complete even if the user leaves — an upload or a sync. That belongs to a scheduled background worker the OS manages, not a scope tied to a screen.

Why is background work unreliable on mobile?

The OS defers and batches it to protect battery, and may not run it at all under Doze or low power. Schedule work as a constraint, and never assume a fixed interval.

Quick Quiz

1. A ViewModel primarily exists to…
2. One sealed state object prevents…
3. Dependency injection primarily gives you…
4. Work tied to a screen should be cancelled when…
5. An upload that must finish after the user leaves belongs in…