Mobile Design Rounds
Build guideAndroidiOS20 min read

Build it: a state machine for a mobile flow on Android and iOS

Last reviewed . Drafted with AI assistance. Checked against official documentation where noted (how this is checked). Kotlin reducer and tests run; Swift not compiled. Part of: Design a state machine for a mobile flow (Android and iOS) · Live data and payments · The five-step method.

This is the implementation material behind the state machine interview case. It covers:

  • the state, event and effect types
  • the pure reducer that holds every rule
  • the store, the saved attempt record and the effect runner
  • tests that prove the double tap and the lost answer
  • platform limits

None of it belongs in a 45-minute round (the length I assume). I draw the types and the table, and write code only if asked. Checked on 15 October 2026: the Kotlin types, reducer and tests compiled with Kotlin 2.1.20 and all six tests passed. The store and runner compiled against small stand-ins for ViewModel and DataStore, not the real AndroidX libraries, and a simulated double tap with a lost answer made one charge. The Swift was not compiled, because I had no Swift toolchain. Compile it before you copy it.

1. The types

Three closed lists: states, events and effects. A when or switch over a closed list tells you at compile time when you forget a case. Events that come from an effect carry the attempt id, so a stale one can be spotted.

1.1 Kotlin

package checkout

data class Form(val cardToken: String? = null, val addressId: String? = null) {
    val valid get() = cardToken != null && addressId != null
}

sealed interface PayResult {
    data class Paid(val orderId: String) : PayResult
    data class Declined(val reason: String) : PayResult
}

sealed interface Status {
    data class Paid(val orderId: String) : Status
    data object NotPaid : Status
    data object Pending : Status
}

/** Where the flow is. Each case holds only the data that makes sense there. */
sealed interface State {
    data class Editing(val form: Form = Form(), val error: String? = null) : State
    data class Submitting(val attemptId: String, val form: Form) : State
    data class Unknown(val attemptId: String, val checks: Int = 0) : State
    data class Succeeded(val orderId: String) : State
    data class Failed(val reason: String, val canRetry: Boolean) : State
}

/** What happened. Every event that comes from an effect carries the attempt id. */
sealed interface Event {
    data class FieldChanged(val form: Form) : Event
    data class PayTapped(val newAttemptId: String) : Event   // the store makes the id, so reduce stays pure
    data class PayAnswered(val attemptId: String, val result: PayResult) : Event
    data class PayTimedOut(val attemptId: String) : Event
    data class Restored(val attemptId: String?) : Event      // app start or return to the foreground
    data class StatusChecked(val attemptId: String, val status: Status) : Event
    data object RetryTapped : Event
}

/** What the flow asks the outside world to do. Effects run in order. */
sealed interface Effect {
    data class SaveAttempt(val attemptId: String) : Effect
    data class Pay(val attemptId: String, val form: Form) : Effect
    data class CheckStatus(val attemptId: String, val delayMs: Long) : Effect
    data object ClearAttempt : Effect
}

data class Step(val state: State, val effects: List<Effect> = emptyList())

1.2 Swift

import Foundation

struct Form: Equatable {
    var cardToken: String?
    var addressId: String?
    var isValid: Bool { cardToken != nil && addressId != nil }
}

enum PayResult: Equatable { case paid(orderId: String), declined(reason: String) }
enum Status: Equatable { case paid(orderId: String), notPaid, pending }

enum State: Equatable {
    case editing(Form, error: String?)
    case submitting(attemptId: String, form: Form)
    case unknown(attemptId: String, checks: Int)
    case succeeded(orderId: String)
    case failed(reason: String, canRetry: Bool)
}

enum Event {
    case fieldChanged(Form)
    case payTapped(newAttemptId: String)
    case payAnswered(attemptId: String, PayResult)
    case payTimedOut(attemptId: String)
    case restored(attemptId: String?)
    case statusChecked(attemptId: String, Status)
    case retryTapped
}

enum Effect: Equatable {
    case saveAttempt(String)
    case pay(attemptId: String, form: Form)
    case checkStatus(attemptId: String, delay: Duration)
    case clearAttempt
}

2. The reducer

This is the whole decision logic. It has no clock, no random numbers and no network, so a test needs no mock. The id for a new attempt comes in with the event. Section 3 shows who makes it.

Four rules to look for in the code:

  • A second PayTapped in Submitting falls to the last branch. Nothing changes and nothing is sent.
  • The effect list for a new payment is SaveAttempt then Pay. They run in that order.
  • An event with the wrong attempt id is dropped.
  • A late PayAnswered in Unknown still wins, because the first request may have arrived after all.

2.1 Kotlin

package checkout

const val MAX_CHECKS = 6

private fun backoffMs(n: Int): Long = minOf(2_000L shl n, 60_000L)

/** Pure. No clock, no random numbers, no network. Same input, same output. */
fun reduce(s: State, e: Event): Step = when (s) {
    is State.Editing -> when (e) {
        is Event.FieldChanged -> Step(s.copy(form = e.form, error = null))
        is Event.PayTapped ->
            if (s.form.valid) Step(
                State.Submitting(e.newAttemptId, s.form),
                // Order matters: the id is on disk before the request leaves.
                listOf(Effect.SaveAttempt(e.newAttemptId), Effect.Pay(e.newAttemptId, s.form)),
            ) else Step(s.copy(error = "Fill in all fields"))
        is Event.Restored ->
            if (e.attemptId != null) Step(State.Unknown(e.attemptId), listOf(Effect.CheckStatus(e.attemptId, 0)))
            else Step(s)
        else -> Step(s)
    }
    is State.Submitting -> when {
        e is Event.PayAnswered && e.attemptId == s.attemptId -> answered(e.result)
        e is Event.PayTimedOut && e.attemptId == s.attemptId ->
            Step(State.Unknown(s.attemptId), listOf(Effect.CheckStatus(s.attemptId, 0)))
        else -> Step(s) // a second PayTapped lands here: nothing changes, nothing is sent
    }
    is State.Unknown -> when {
        e is Event.PayAnswered && e.attemptId == s.attemptId -> answered(e.result) // a late answer
        e is Event.StatusChecked && e.attemptId == s.attemptId -> when (val st = e.status) {
            is Status.Paid -> Step(State.Succeeded(st.orderId), listOf(Effect.ClearAttempt))
            Status.NotPaid -> Step(State.Editing(), listOf(Effect.ClearAttempt))
            Status.Pending ->
                if (s.checks + 1 >= MAX_CHECKS) Step(s.copy(checks = s.checks + 1)) // stop asking; the screen says what to do
                else Step(s.copy(checks = s.checks + 1), listOf(Effect.CheckStatus(s.attemptId, backoffMs(s.checks + 1))))
        }
        e is Event.Restored -> Step(s, listOf(Effect.CheckStatus(s.attemptId, 0)))
        else -> Step(s)
    }
    is State.Succeeded -> Step(s)
    is State.Failed -> if (e is Event.RetryTapped && s.canRetry) Step(State.Editing()) else Step(s)
}

private fun answered(r: PayResult): Step = when (r) {
    is PayResult.Paid -> Step(State.Succeeded(r.orderId), listOf(Effect.ClearAttempt))
    is PayResult.Declined -> Step(State.Failed(r.reason, canRetry = true), listOf(Effect.ClearAttempt))
}

2.2 Swift

let maxChecks = 6

private func backoff(_ n: Int) -> Duration { .seconds(min(2 << n, 60)) }

private func answered(_ r: PayResult) -> (State, [Effect]) {
    switch r {
    case .paid(let id): return (.succeeded(orderId: id), [.clearAttempt])
    case .declined(let why): return (.failed(reason: why, canRetry: true), [.clearAttempt])
    }
}

/// Pure. Same input, same output.
func reduce(_ s: State, _ e: Event) -> (State, [Effect]) {
    switch (s, e) {
    case (.editing(_, _), .fieldChanged(let f)):
        return (.editing(f, error: nil), [])
    case (.editing(let f, _), .payTapped(let id)):
        guard f.isValid else { return (.editing(f, error: "Fill in all fields"), []) }
        // Order matters: the id is on disk before the request leaves.
        return (.submitting(attemptId: id, form: f), [.saveAttempt(id), .pay(attemptId: id, form: f)])
    case (.editing, .restored(let id?)):
        return (.unknown(attemptId: id, checks: 0), [.checkStatus(attemptId: id, delay: .zero)])

    case (.submitting(let id, _), .payAnswered(let a, let r)) where a == id:
        return answered(r)
    case (.submitting(let id, _), .payTimedOut(let a)) where a == id:
        return (.unknown(attemptId: id, checks: 0), [.checkStatus(attemptId: id, delay: .zero)])

    case (.unknown(let id, _), .payAnswered(let a, let r)) where a == id:   // a late answer
        return answered(r)
    case (.unknown(let id, let n), .statusChecked(let a, let st)) where a == id:
        switch st {
        case .paid(let order): return (.succeeded(orderId: order), [.clearAttempt])
        case .notPaid: return (.editing(Form(), error: nil), [.clearAttempt])
        case .pending:
            if n + 1 >= maxChecks { return (.unknown(attemptId: id, checks: n + 1), []) }
            return (.unknown(attemptId: id, checks: n + 1), [.checkStatus(attemptId: id, delay: backoff(n + 1))])
        }
    case (.unknown(let id, _), .restored):
        return (s, [.checkStatus(attemptId: id, delay: .zero)])

    case (.failed(_, true), .retryTapped):
        return (.editing(Form(), error: nil), [])

    default:
        return (s, [])   // a second payTapped, a stale answer, anything else: nothing changes
    }
}

The backoff is 4, 8, 16, 32 and 60 seconds, and it stops after six checks. That is my choice, not a documented value. After the cap the screen says "We could not confirm. Do not pay again" and the Pay button stays gone.

3. The store

The store owns the state and feeds events through one queue. It makes the attempt id, runs the reducer, publishes the new state and hands the effects to the runner. On Android a Channel gives the queue. On iOS the main actor gives it for free.

Effects run in an app-scoped scope on Android, and in a store that lives as long as the app on iOS. A ViewModel is cleared when its screen leaves (Android ViewModel), and a payment call should not be cancelled by that.

3.1 Kotlin

package checkout

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.util.UUID

class CheckoutViewModel(
    private val runner: EffectRunner,
    private val effectScope: CoroutineScope,          // app-scoped: a payment must not die with the screen
    private val newId: () -> String = { UUID.randomUUID().toString() },
    private val savedAttempt: suspend () -> String?,
) : ViewModel() {
    private val _state = MutableStateFlow<State>(State.Editing())
    val state: StateFlow<State> = _state

    private val events = Channel<Event>(Channel.UNLIMITED)

    init {
        viewModelScope.launch { for (e in events) apply(e) }   // one event at a time, in arrival order
        viewModelScope.launch { send(Event.Restored(savedAttempt())) }
    }

    fun send(e: Event) { events.trySend(e) }
    fun onPayClicked() = send(Event.PayTapped(newId()))

    private fun apply(e: Event) {
        val step = reduce(_state.value, e)
        _state.value = step.state
        if (step.effects.isNotEmpty()) effectScope.launch { runner.run(step.effects, ::send) }
    }
}

3.2 Swift

import SwiftUI

@MainActor
final class CheckoutStore: ObservableObject {
    @Published private(set) var state: State
    private let run: (Effect) async -> Event?
    private let newId: () -> String

    init(state: State = .editing(Form(), error: nil),
         newId: @escaping () -> String = { UUID().uuidString },
         run: @escaping (Effect) async -> Event?) {
        self.state = state
        self.newId = newId
        self.run = run
    }

    func payTapped() { send(.payTapped(newAttemptId: newId())) }

    /// Runs on the main actor, so events are handled one at a time, in order.
    func send(_ event: Event) {
        let (next, effects) = reduce(state, event)
        state = next
        guard !effects.isEmpty else { return }
        Task { [run] in
            for fx in effects {                       // in order: save, then pay
                if let result = await run(fx) { send(result) }
            }
        }
    }
}

4. The saved attempt and the effect runner

The runner is the only place that touches the network and the disk. It turns every outcome into an event, including a timeout. A failed status check becomes Pending, which keeps the flow in Unknown.

Where the record goes matters:

  • Android: DataStore describes its updates as transactional (DataStore). I use it, not SavedStateHandle. SavedStateHandle is lost on a swipe from recents, a force stop or a reboot, and is not long-term storage (ViewModel saved state).
  • iOS: SceneStorage makes no guarantee about when it writes, and is for lightweight screen state (Apple SceneStorage). I use a file written in one piece. That atomic write behavior is from memory and I did not verify it.

4.1 Kotlin

package checkout

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeout
import java.io.IOException

interface PaymentApi {
    suspend fun pay(attemptId: String, form: Form): PayResult   // POST /orders, Idempotency-Key = attemptId
    suspend fun status(attemptId: String): Status               // GET /orders?attempt=ID
}

/** The one record that must outlive the process. DataStore edits are transactional. */
class AttemptStore(private val ds: DataStore<Preferences>) {
    private val key = stringPreferencesKey("open_attempt")
    suspend fun save(id: String) { ds.edit { it[key] = id } }
    suspend fun clear() { ds.edit { it.remove(key) } }
    suspend fun load(): String? = ds.data.first()[key]
}

interface EffectRunner { suspend fun run(effects: List<Effect>, send: (Event) -> Unit) }

class RealEffectRunner(private val api: PaymentApi, private val store: AttemptStore) : EffectRunner {
    override suspend fun run(effects: List<Effect>, send: (Event) -> Unit) {
        for (fx in effects) when (fx) {
            is Effect.SaveAttempt -> store.save(fx.attemptId)       // finishes before the next effect starts
            is Effect.Pay -> send(
                try {
                    Event.PayAnswered(fx.attemptId, withTimeout(30_000) { api.pay(fx.attemptId, fx.form) })
                } catch (e: TimeoutCancellationException) {
                    Event.PayTimedOut(fx.attemptId)
                } catch (e: IOException) {
                    Event.PayTimedOut(fx.attemptId)                 // we do not know if it arrived
                }
            )
            is Effect.CheckStatus -> {
                delay(fx.delayMs)
                send(Event.StatusChecked(fx.attemptId, try { api.status(fx.attemptId) } catch (e: IOException) { Status.Pending }))
            }
            Effect.ClearAttempt -> store.clear()
        }
    }
}

4.2 Swift

import Foundation

protocol PaymentAPI {
    func pay(attemptId: String, form: Form) async throws -> PayResult   // POST /orders, Idempotency-Key = attemptId
    func status(attemptId: String) async throws -> Status               // GET /orders?attempt=ID
}

/// The one record that must outlive the process. A file, written all at once.
struct AttemptFile {
    let url: URL
    func save(_ id: String) throws { try Data(id.utf8).write(to: url, options: .atomic) }
    func load() -> String? { (try? Data(contentsOf: url)).flatMap { String(data: $0, encoding: .utf8) } }
    func clear() { try? FileManager.default.removeItem(at: url) }
}

func makeRunner(api: PaymentAPI, file: AttemptFile) -> (Effect) async -> Event? {
    { fx in
        switch fx {
        case .saveAttempt(let id):
            try? file.save(id)
            return nil
        case .pay(let id, let form):
            do {
                // URLSession's own timeout also ends the call; use one you chose on purpose.
                return .payAnswered(attemptId: id, try await api.pay(attemptId: id, form: form))
            } catch {
                return .payTimedOut(attemptId: id)            // we do not know if it arrived
            }
        case .checkStatus(let id, let delay):
            try? await Task.sleep(for: delay)
            let st = (try? await api.status(attemptId: id)) ?? .pending
            return .statusChecked(attemptId: id, st)
        case .clearAttempt:
            file.clear()
            return nil
        }
    }
}

Wire it up so that Restored is sent at app start and when the app returns to the foreground. On Android the ViewModel sends it from init in section 3. On iOS call store.send(.restored(attemptId: file.load())) from onChange(of: scenePhase) when the phase is .active, and once at launch.

5. Tests

Because reduce is pure, each rule from the interview page is a few lines. The last Kotlin test sends every event to every state and checks two things: no pair throws, and only Editing can ask for a payment. That test is the guard against a new state that forgets a row.

5.1 Kotlin

package checkout

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class ReduceTest {
    private val form = Form("tok", "addr")

    @Test fun `a second tap in Submitting changes nothing and sends nothing`() {
        val s = State.Submitting("A1", form)
        assertEquals(Step(s), reduce(s, Event.PayTapped("A2")))
    }

    @Test fun `the id is saved before the request is sent`() {
        val step = reduce(State.Editing(form), Event.PayTapped("A1"))
        assertEquals(listOf(Effect.SaveAttempt("A1"), Effect.Pay("A1", form)), step.effects)
    }

    @Test fun `a saved attempt after a restart starts in Unknown and asks the server`() {
        val step = reduce(State.Editing(), Event.Restored("A1"))
        assertEquals(State.Unknown("A1"), step.state)
        assertEquals(listOf(Effect.CheckStatus("A1", 0)), step.effects)
    }

    @Test fun `an answer for an old attempt is dropped`() {
        val s = State.Submitting("A2", form)
        assertEquals(Step(s), reduce(s, Event.PayAnswered("A1", PayResult.Paid("o1"))))
    }

    @Test fun `a late answer in Unknown still wins`() {
        val step = reduce(State.Unknown("A1"), Event.PayAnswered("A1", PayResult.Paid("o1")))
        assertEquals(State.Succeeded("o1"), step.state)
    }

    @Test fun `Pay is only ever asked for from Editing`() {
        val states = listOf(
            State.Editing(form), State.Submitting("A1", form), State.Unknown("A1"),
            State.Succeeded("o1"), State.Failed("declined", true),
        )
        val events = listOf(
            Event.FieldChanged(form), Event.PayTapped("A9"), Event.PayAnswered("A1", PayResult.Declined("x")),
            Event.PayTimedOut("A1"), Event.Restored("A1"), Event.Restored(null),
            Event.StatusChecked("A1", Status.NotPaid), Event.StatusChecked("A1", Status.Pending), Event.RetryTapped,
        )
        for (s in states) for (e in events) {
            val step = reduce(s, e)            // must not throw: every pair has an answer
            if (s !is State.Editing) assertTrue(step.effects.none { it is Effect.Pay }, "$s + $e")
        }
    }
}

5.2 Swift

import XCTest

final class ReduceTests: XCTestCase {
    let form = Form(cardToken: "tok", addressId: "addr")

    func testSecondTapIsIgnored() {
        let s = State.submitting(attemptId: "A1", form: form)
        let (next, fx) = reduce(s, .payTapped(newAttemptId: "A2"))
        XCTAssertEqual(next, s)
        XCTAssertTrue(fx.isEmpty)
    }

    func testIdIsSavedBeforeRequest() {
        let (_, fx) = reduce(.editing(form, error: nil), .payTapped(newAttemptId: "A1"))
        XCTAssertEqual(fx, [.saveAttempt("A1"), .pay(attemptId: "A1", form: form)])
    }

    func testRestartWithSavedAttemptStartsInUnknown() {
        let (next, fx) = reduce(.editing(Form(), error: nil), .restored(attemptId: "A1"))
        XCTAssertEqual(next, .unknown(attemptId: "A1", checks: 0))
        XCTAssertEqual(fx, [.checkStatus(attemptId: "A1", delay: .zero)])
    }

    func testStaleAnswerIsDropped() {
        let s = State.submitting(attemptId: "A2", form: form)
        let (next, _) = reduce(s, .payAnswered(attemptId: "A1", .paid(orderId: "o1")))
        XCTAssertEqual(next, s)
    }
}

One more test needs a device or an emulator: start a payment, kill the process in Submitting, start the app, and check that the first state is Unknown and that no second POST was sent. Replay a logged event list into reduce to reproduce a bug report.

6. Platform limits

TopicAndroidiOSBasis
Process endThe system may destroy the app process while the user is away. A ViewModel is destroyed with it.The app can be ended after it goes to the background. Expect it when the phase is background.Android saving UI state; Apple ScenePhase
Small saved UI stateSavedStateHandle survives a system-initiated process end. Lost on swipe from recents, force stop or reboot. Keep it small.SceneStorage restores a scene. No guarantee when it is written. Lightweight data only. Lost if the scene is destroyed.ViewModel saved state; Apple SceneStorage
The attempt recordDataStore (transactional edits) or Room.A file written whole, or the Keychain or a database.DataStore; atomic write: not verified
Foreground signalLifecycle ON_START or a process lifecycle observer. Not verified here.scenePhase == .active: foreground and interactive.Apple ScenePhase
Idempotency keySame on both: sent with the request, kept by the server.Same.Stripe idempotent requests: kept at least 24 hours on API v1, different parameters with the same key are an error

The rows come from the pages I read on 15 October 2026, except where the table says not verified.

7. More practice prompts

  1. Add a coupon step that can fail on its own. Which rows change?
  2. The server answers 409 when the attempt id was already used with other parameters. Which event is that, and which state follows?
  3. Two checkouts open at once on a tablet in two windows. Where does the saved attempt id live, and what must the second window do?
  4. Replace the 30 second timeout with the one your server team recommends. What else in the table depends on it?
  5. Write the Unknown screen copy for the case after six checks. What must it never say?

Further reading (official docs)

Verification notes (last reviewed 15 Oct 2026)

I fetched these pages on 2026-10-15. Android saving UI state: the system may destroy the app process while the user is away; ViewModels are destroyed then; saved state survives a system-initiated end but is not long-term storage. ViewModel saved state: lost on swipe from recents, force stop or reboot. DataStore: updates are transactional and atomic read-modify-write; the page does not describe what happens when the process is killed mid-write. Stripe: Idempotency-Key header, keys may be pruned after at least 24 hours on API v1, the same key with different parameters is an error. SceneStorage: lightweight data, no guarantee when it is written, destroyed with the scene. ScenePhase: active is foreground and interactive, and a background phase means expect the app to end soon. Not verified: that Data.write(options: .atomic) replaces the file in one step (from memory; the Apple page did not load); the Android foreground signal row; that 2_000L shl n and 2 << n give the same schedule (they do by arithmetic: 2 shifted by n is 2 times 2 to the n). The Kotlin types, reducer and tests were compiled with Kotlin 2.1.20 and all six tests passed. The Kotlin store and runner compiled against stand-in ViewModel and DataStore classes, so the real AndroidX calls are not checked. The Swift sample was not compiled. The reducer, tests, backoff, timeouts and the idea of an Unknown state are my design.

Get the free one-page cheat sheet

One page: the five steps, a 45-minute time plan and two checklists. Print it or keep it on your phone.

We email you the cheat sheet after you confirm your address. You can also open it now from the Start here page. You get a short note when a new case is published. Unsubscribe any time. Privacy.