Skip to content
Commitwise
Menu
Design Patterns

MVC, MVP, MVVM and MVI

Split a screen into view, logic and data with MVC, MVP, MVVM and MVI, see how each looks in SwiftUI, Jetpack Compose, Android with Java and TypeScript, and avoid the leaks, stale updates and replayed events that come with each.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The login screen started as 40 lines. Now it’s 900: it validates the email, calls the API, retries, stores the token, formats errors, decides where to navigate, and draws the UI. Nobody can test any of it without launching the app, and every change breaks something. MVC, MVP, MVVM and MVI are four answers to the same question: which code belongs in the screen, and which doesn’t?

New to some of these? Open what you need, or skip ahead.

Basics:How do SwiftUI views and composables work?

In SwiftUI (iOS) and Jetpack Compose (Android), you don’t change the screen directly. You write a description of what the screen should look like for the current data, and the framework redraws it when that data changes. Web frameworks like React work the same way. That’s why these architectures care so much about where the data lives and how changes reach the screen.

Basics:What is the Observer pattern?

An object that changes (the subject) keeps a list of observers and notifies them when it changes, without knowing who they are. SwiftUI’s @Observable, Kotlin’s StateFlow, Android’s LiveData and RxJS observables are all ways for a screen to observe data. The Observer tutorial covers it in detail.

Basics:What are unit tests and fakes?

A unit test is a small piece of code that checks one piece of your app automatically: call a function with known input, and compare the result with what you expect. Tests run in seconds, so you run them after every change.

A fake is a simple stand-in for something slow or risky, like a payment provider or a server. It returns prepared answers instead of charging a card, so the test is fast, repeatable and safe.

Part Not started

Why split a screen at all

In a restaurant, the waiter takes orders and serves plates, the kitchen cooks, and the pantry stores ingredients. If the waiter also cooked and did the shopping, every busy evening would be chaos, and you couldn’t train a new cook without also training them to wait tables.

A screen has the same three jobs: showing things and receiving taps (the View), the data and its rules (the Model), and deciding what happens when the user does something (the logic in between). The four patterns differ mostly in what that middle piece is called and how it talks to the View.

The common goal is separating presentation logic from rendering and from domain logic, so the middle layer is testable without a UI runtime and the view is thin enough to be obviously correct. The patterns differ in the direction and form of communication: who holds a reference to whom, whether the view pulls observable state or is pushed commands, and whether state is many mutable properties or one immutable value.

PatternThe middle pieceHow the view gets updatesWho references whom
MVC (Apple-style)ControllerController sets view properties directlyController owns view and model
MVPPresenterPresenter calls methods on a View interfacePresenter → View interface; View → Presenter
MVVMViewModelView observes properties the ViewModel exposesView → ViewModel only
MVIStore / ViewModel with a reducerView observes one immutable state; sends intentsView → Store only; one-way loop
struct LoginView: View {
    @State private var email = ""
    @State private var password = ""
    @State private var error: String?

    var body: some View {
        Form {
            TextField("Email", text: $email)
            SecureField("Password", text: $password)
            if let error { Text(error).foregroundStyle(.red) }
            Button("Log in") {
                guard email.contains("@") else { error = "Invalid email"; return }   // validation
                Task {
                    do {
                        let token = try await API.shared.login(email, password)      // networking
                        Keychain.save(token)                                          // storage
                    } catch { self.error = "Wrong email or password" }               // error mapping
                }
            }
        }
    }
}
@Composable
fun LoginScreen() {
    var email by remember { mutableStateOf("") }
    var password by remember { mutableStateOf("") }
    var error by remember { mutableStateOf<String?>(null) }
    val scope = rememberCoroutineScope()

    Column {
        TextField(email, { email = it }, label = { Text("Email") })
        TextField(password, { password = it }, label = { Text("Password") })
        error?.let { Text(it, color = Color.Red) }
        Button(onClick = {
            if ("@" !in email) { error = "Invalid email"; return@Button }       // validation
            scope.launch {
                try {
                    val token = Api.login(email, password)                      // networking
                    TokenStore.save(token)                                       // storage
                } catch (e: Exception) { error = "Wrong email or password" }    // error mapping
            }
        }) { Text("Log in") }
    }
}
public class LoginActivity extends AppCompatActivity {
    @Override protected void onCreate(Bundle state) {
        super.onCreate(state);
        setContentView(R.layout.activity_login);
        EditText email = findViewById(R.id.email);
        EditText password = findViewById(R.id.password);
        TextView error = findViewById(R.id.error);
        findViewById(R.id.login).setOnClickListener(v -> {
            if (!email.getText().toString().contains("@")) { error.setText("Invalid email"); return; }  // validation
            api.login(email.getText().toString(), password.getText().toString(), new Callback<>() {     // networking
                public void onSuccess(String token) { tokenStore.save(token); }                           // storage
                public void onError(Exception e) { error.setText("Wrong email or password"); }           // error mapping
            });
        });
    }
}
function LoginPage(root: HTMLElement): void {
  const email = root.querySelector<HTMLInputElement>("#email")!;
  const password = root.querySelector<HTMLInputElement>("#password")!;
  const error = root.querySelector<HTMLElement>("#error")!;
  root.querySelector("#login")!.addEventListener("click", async () => {
    if (!email.value.includes("@")) { error.textContent = "Invalid email"; return; }   // validation
    try {
      const token = await api.login(email.value, password.value);                      // networking
      tokenStore.save(token);                                                          // storage
    } catch { error.textContent = "Wrong email or password"; }                         // error mapping
  });
}

It works, but five jobs are tangled with drawing the screen. To test “an invalid email shows an error”, you need a running UI. On Android, rotating the phone recreates the screen and a login in progress loses its result. The rest of this lesson moves those jobs out of the view, one pattern at a time.

Practice: sort the code by job

  1. Mark each line of the screen above: drawing, validation, networking, storage, error messages, navigation.
  2. Drawing stays in the view: the fields, the button, showing the error text.
  3. The decisions (“is this email valid?”, “what message for this error?”, “what happens after login?”) go to the middle piece: controller, presenter, ViewModel or store.
  4. Data access (API, keychain/token storage) goes behind interfaces in the model layer (often called a repository), passed in with dependency injection, so the middle piece can be tested with fakes.
  5. Test the middle piece without any UI: “given an invalid email, the error state says Invalid email”.

What “Massive View Controller” really means

Apple’s MVC intended the controller as a thin mediator, but UIViewController owns the view hierarchy, lifecycle callbacks, delegates and navigation, so logic accumulates there by default. The same happened with Android’s Activity and with “fat components” on the web. The other patterns are mostly disciplines for keeping logic out of the framework class that you can’t instantiate in a unit test. SwiftUI and Compose shifted the default: views are cheap descriptions, so state must live somewhere else anyway, which made MVVM and MVI the natural fits.

  • Goal: presentation logic testable without a UI runtime; views thin enough to be obviously correct; domain logic below both.
  • MVC (Apple): controller owns view and model, tends to grow; MVP: presenter drives a view interface; MVVM: view observes VM state; MVI: one immutable state, intents, reducer, one-way loop.
  • Declarative UI (SwiftUI, Compose, React) makes state ownership explicit, which favors MVVM/MVI.

Check this part

  1. What should stay in the View?

  2. What's the main benefit of moving logic out of the view?

  3. Which pattern has the view observe a single immutable state and send intents?

All four patterns split a screen into a Model (data and business rules), a View (drawing and input) and something in between that holds the screen's logic. MVC puts it in a controller that usually grows too big; the others make the middle piece testable without the UI.

Part Not started

MVP and MVVM

MVP is like a director with an actor: the director (presenter) tells the actor (view) exactly what to do, “show this error”, “disable the button”. The actor follows instructions.

MVVM is like a scoreboard: the ViewModel keeps the current score (the state), and the view simply displays whatever the scoreboard says, updating by itself when it changes. The scoreboard doesn’t know who’s watching.

Modern iOS and Android apps mostly use MVVM, because SwiftUI and Compose are built around “show whatever the state currently is”.

MVP’s presenter holds a reference to a view interface and pushes imperative updates; it’s testable with a fake view, but requires careful attach/detach to avoid leaks and calls into dead views. MVVM inverts it: the ViewModel exposes observable state and has no reference to the view, so it survives configuration changes (Android ViewModel) and can be observed by any number of views. The cost is that state shape and update granularity become the design problem (many properties vs one state object, which leads to MVI).

@Observable @MainActor
final class LoginViewModel {
    var email = ""
    var password = ""
    private(set) var error: String?
    private(set) var isLoading = false
    private(set) var loggedIn = false

    private let auth: AuthService
    init(auth: AuthService) { self.auth = auth }

    var canSubmit: Bool { email.contains("@") && !password.isEmpty && !isLoading }

    func logIn() async {
        isLoading = true
        defer { isLoading = false }
        do {
            try await auth.logIn(email: email, password: password)
            loggedIn = true
        } catch {
            self.error = "Wrong email or password"
        }
    }
}

struct LoginView: View {
    @State var model: LoginViewModel

    var body: some View {
        Form {
            TextField("Email", text: $model.email)
            SecureField("Password", text: $model.password)
            if let error = model.error { Text(error).foregroundStyle(.red) }
            Button("Log in") { Task { await model.logIn() } }
                .disabled(!model.canSubmit)
        }
    }
}
data class LoginUiState(
    val email: String = "",
    val password: String = "",
    val error: String? = null,
    val isLoading: Boolean = false,
) {
    val canSubmit get() = "@" in email && password.isNotEmpty() && !isLoading
}

class LoginViewModel(private val auth: AuthService) : ViewModel() {
    private val _state = MutableStateFlow(LoginUiState())
    val state: StateFlow<LoginUiState> = _state

    fun onEmailChange(value: String) = _state.update { it.copy(email = value, error = null) }
    fun onPasswordChange(value: String) = _state.update { it.copy(password = value) }

    fun logIn() = viewModelScope.launch {
        _state.update { it.copy(isLoading = true) }
        val result = runCatching { auth.logIn(_state.value.email, _state.value.password) }
        _state.update {
            it.copy(isLoading = false, error = if (result.isFailure) "Wrong email or password" else null)
        }
    }
}

@Composable
fun LoginScreen(viewModel: LoginViewModel) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    Column {
        TextField(state.email, viewModel::onEmailChange, label = { Text("Email") })
        TextField(state.password, viewModel::onPasswordChange, label = { Text("Password") })
        state.error?.let { Text(it, color = Color.Red) }
        Button(onClick = { viewModel.logIn() }, enabled = state.canSubmit) { Text("Log in") }
    }
}
public class LoginViewModel extends ViewModel {
    private final AuthService auth;
    private final MutableLiveData<String> error = new MutableLiveData<>(null);
    private final MutableLiveData<Boolean> loading = new MutableLiveData<>(false);

    public LoginViewModel(AuthService auth) { this.auth = auth; }

    public LiveData<String> error() { return error; }
    public LiveData<Boolean> loading() { return loading; }

    public void logIn(String email, String password) {
        if (!email.contains("@")) { error.setValue("Invalid email"); return; }
        loading.setValue(true);
        auth.logIn(email, password, new Callback<>() {
            @Override public void onSuccess(Void v) { loading.postValue(false); }
            @Override public void onError(Exception e) {
                loading.postValue(false);
                error.postValue("Wrong email or password");
            }
        });
    }
}

// In the Activity:
// viewModel = new ViewModelProvider(this, factory).get(LoginViewModel.class);
// viewModel.error().observe(this, message -> errorView.setText(message));
// loginButton.setOnClickListener(v -> viewModel.logIn(email.getText().toString(), password.getText().toString()));
type LoginState = { email: string; password: string; error?: string; isLoading: boolean };

class LoginViewModel {
  private state: LoginState = { email: "", password: "", isLoading: false };
  private readonly listeners = new Set<(s: LoginState) => void>();

  constructor(private readonly auth: AuthService) {}

  subscribe(listener: (s: LoginState) => void): () => void {
    this.listeners.add(listener);
    listener(this.state);
    return () => this.listeners.delete(listener);
  }

  setEmail(email: string) { this.set({ email, error: undefined }); }
  setPassword(password: string) { this.set({ password }); }

  async logIn(): Promise<void> {
    this.set({ isLoading: true });
    try {
      await this.auth.logIn(this.state.email, this.state.password);
      this.set({ isLoading: false });
    } catch {
      this.set({ isLoading: false, error: "Wrong email or password" });
    }
  }

  private set(patch: Partial<LoginState>): void {
    this.state = { ...this.state, ...patch };     // a new object every time
    this.listeners.forEach((l) => l(this.state));
  }
}

The ViewModel holds the screen’s state and logic, and talks to the outside world through AuthService. It knows nothing about text fields or colors. The view renders the state and forwards taps. Testing “wrong password shows an error” means creating a LoginViewModel with a fake AuthService that fails, calling logIn, and checking error. No UI involved.

Spot the bugCrashes after the user rotates the phone

Tap the line with the bug.

@Observable @MainActor
final class ProfileViewModel {
    let screen: ProfileViewController
    init(screen: ProfileViewController) { self.screen = screen }

    func load() async {
        let profile = try? await api.profile()
        screen.nameLabel.text = profile?.name
    }
}
class ProfileViewModel(private val activity: ProfileActivity) : ViewModel() {
    fun load() = viewModelScope.launch {
        val profile = api.profile()
        activity.findViewById<TextView>(R.id.name).text = profile.name
    }
}
public class ProfileViewModel extends ViewModel {
    private final ProfileActivity activity;
    public ProfileViewModel(ProfileActivity activity) { this.activity = activity; }

    public void load() {
        api.profile(profile -> activity.nameView().setText(profile.name()));
    }
}
class ProfileViewModel {
  constructor(private readonly page: ProfilePage) {}

  async load(): Promise<void> {
    const profile = await api.profile();
    this.page.nameElement.textContent = profile.name;
  }
}

State that doesn’t update

Predict the output

The ViewModel adds an item by mutating the list already held in its observable state. Does the screen update?

@Observable final class CartViewModel {
    var items: [String] = []
    func add(_ item: String) { items.append(item) }
}
class CartViewModel : ViewModel() {
    val state = MutableStateFlow(CartState(items = mutableListOf()))
    fun add(item: String) { state.value.items.add(item) }
}
public class CartViewModel extends ViewModel {
    private final MutableLiveData<List<String>> items = new MutableLiveData<>(new ArrayList<>());
    public void add(String item) { items.getValue().add(item); }
}
class CartViewModel {
  private state = { items: [] as string[] };
  add(item: string) { this.state.items.push(item); }
}

The fix everywhere except Swift’s value-typed arrays is the same: observable state holds immutable values, and every change publishes a new one (see Immutability).

  • MVP: presenter → view interface (imperative); attach/detach discipline; testable with a fake view.
  • MVVM: VM exposes observable state, no view references (no Activity/Context/UIViewController/DOM in VM imports); survives recreation; any number of observers.
  • Observable state must change by publishing new immutable values (StateFlow.update { copy() }, new lists for LiveData/ListAdapter, spread in TS); Swift @Observable arrays work via value semantics.
  • Collect with lifecycle awareness (collectAsStateWithLifecycle, observe(owner)).

Check this part

  1. In MVVM, how does the view learn about changes?

  2. Why must a ViewModel not hold a reference to its Activity or view controller?

  3. With @Observable, does items.append(x) on an array property update the view?Does state.value.items.add(x) on a MutableStateFlow update Compose?Does liveData.getValue().add(x) notify observers?Does pushing into the current state's array re-render subscribers?

In MVP, a presenter tells a passive view what to show through an interface. In MVVM, a ViewModel exposes observable state and the view renders whatever it currently says; the ViewModel never references the view, so it survives screen recreation and is easy to test.

Part Not started

MVI: one state, one direction

A shared spreadsheet has one current version, and every change goes through the same process: someone submits a change, it’s applied, and everyone sees the new version. Nobody edits their own copy behind the scenes.

MVI (Model-View-Intent) works like that. The screen has one state object. The view shows it and sends intents (“email changed”, “log in tapped”). A single function, the reducer, takes the current state and an intent and returns the next state. Data only flows in one direction, which makes every change easy to follow and to test.

MVI (and Redux, The Composable Architecture, Elm) formalizes MVVM’s state into a single immutable value updated only by a pure reducer (State, Intent) → State, with side effects modeled explicitly (returned as effects, or launched by the store in response to intents) and their results fed back as intents. It’s unidirectional data flow with discipline: time-travel debugging, trivially testable transitions, no partial updates. The costs are boilerplate, care with high-frequency updates (text input), and modeling one-off events, which aren’t state.

struct LoginState: Equatable {
    var email = ""
    var password = ""
    var isLoading = false
    var error: String?
}

enum LoginIntent {
    case emailChanged(String), passwordChanged(String)
    case logInTapped
    case loginFailed
}

func reduce(_ state: LoginState, _ intent: LoginIntent) -> LoginState {
    var next = state
    switch intent {
    case .emailChanged(let e):    next.email = e; next.error = nil
    case .passwordChanged(let p): next.password = p
    case .logInTapped:            next.isLoading = true; next.error = nil
    case .loginFailed:            next.isLoading = false; next.error = "Wrong email or password"
    }
    return next
}
data class LoginState(
    val email: String = "",
    val password: String = "",
    val isLoading: Boolean = false,
    val error: String? = null,
)

sealed interface LoginIntent {
    data class EmailChanged(val email: String) : LoginIntent
    data class PasswordChanged(val password: String) : LoginIntent
    data object LogInTapped : LoginIntent
    data object LoginFailed : LoginIntent
}

fun reduce(state: LoginState, intent: LoginIntent): LoginState = when (intent) {
    is LoginIntent.EmailChanged -> state.copy(email = intent.email, error = null)
    is LoginIntent.PasswordChanged -> state.copy(password = intent.password)
    LoginIntent.LogInTapped -> state.copy(isLoading = true, error = null)
    LoginIntent.LoginFailed -> state.copy(isLoading = false, error = "Wrong email or password")
}
record LoginState(String email, String password, boolean isLoading, String error) {
    static final LoginState INITIAL = new LoginState("", "", false, null);
}

sealed interface LoginIntent {
    record EmailChanged(String email) implements LoginIntent {}
    record PasswordChanged(String password) implements LoginIntent {}
    record LogInTapped() implements LoginIntent {}
    record LoginFailed() implements LoginIntent {}
}

static LoginState reduce(LoginState s, LoginIntent intent) {
    return switch (intent) {
        case LoginIntent.EmailChanged e -> new LoginState(e.email(), s.password(), s.isLoading(), null);
        case LoginIntent.PasswordChanged p -> new LoginState(s.email(), p.password(), s.isLoading(), s.error());
        case LoginIntent.LogInTapped t -> new LoginState(s.email(), s.password(), true, null);
        case LoginIntent.LoginFailed f -> new LoginState(s.email(), s.password(), false, "Wrong email or password");
    };
}
type LoginState = Readonly<{ email: string; password: string; isLoading: boolean; error?: string }>;

type LoginIntent =
  | { type: "emailChanged"; email: string }
  | { type: "passwordChanged"; password: string }
  | { type: "logInTapped" }
  | { type: "loginFailed" };

function reduce(state: LoginState, intent: LoginIntent): LoginState {
  switch (intent.type) {
    case "emailChanged": return { ...state, email: intent.email, error: undefined };
    case "passwordChanged": return { ...state, password: intent.password };
    case "logInTapped": return { ...state, isLoading: true, error: undefined };
    case "loginFailed": return { ...state, isLoading: false, error: "Wrong email or password" };
  }
}

The reducer only decides; it doesn’t call the network. The store around it does: when it sees logInTapped, it reduces (showing the spinner) and then starts the login call; when the call fails, it sends loginFailed back through the same reducer. Every change to the screen passes through one function you can test with plain values: “given this state and this intent, the next state is…”.

Exerciseeasy

A quantity stepper

A product card has a quantity between 1 and 10, with − and + buttons, and an “Add to cart” button that shows “Added ✓” for the current quantity after it’s tapped. Changing the quantity again hides the “Added ✓”. Write the state, the intents and the reducer.

›

State: quantity and whether it was added. Intents: increment, decrement, addTapped. The reducer clamps the quantity between 1 and 10.

SolutionTry it yourself first
struct StepperState: Equatable { var quantity = 1; var added = false }
enum StepperIntent { case increment, decrement, addTapped }

func reduce(_ s: StepperState, _ intent: StepperIntent) -> StepperState {
    var next = s
    switch intent {
    case .increment: next.quantity = min(s.quantity + 1, 10); next.added = false
    case .decrement: next.quantity = max(s.quantity - 1, 1); next.added = false
    case .addTapped: next.added = true
    }
    return next
}
data class StepperState(val quantity: Int = 1, val added: Boolean = false)
enum class StepperIntent { INCREMENT, DECREMENT, ADD_TAPPED }

fun reduce(s: StepperState, intent: StepperIntent): StepperState = when (intent) {
    StepperIntent.INCREMENT -> s.copy(quantity = minOf(s.quantity + 1, 10), added = false)
    StepperIntent.DECREMENT -> s.copy(quantity = maxOf(s.quantity - 1, 1), added = false)
    StepperIntent.ADD_TAPPED -> s.copy(added = true)
}
record StepperState(int quantity, boolean added) {}
enum StepperIntent { INCREMENT, DECREMENT, ADD_TAPPED }

static StepperState reduce(StepperState s, StepperIntent intent) {
    return switch (intent) {
        case INCREMENT -> new StepperState(Math.min(s.quantity() + 1, 10), false);
        case DECREMENT -> new StepperState(Math.max(s.quantity() - 1, 1), false);
        case ADD_TAPPED -> new StepperState(s.quantity(), true);
    };
}
type StepperState = Readonly<{ quantity: number; added: boolean }>;
type StepperIntent = "increment" | "decrement" | "addTapped";

function reduce(s: StepperState, intent: StepperIntent): StepperState {
  switch (intent) {
    case "increment": return { quantity: Math.min(s.quantity + 1, 10), added: false };
    case "decrement": return { quantity: Math.max(s.quantity - 1, 1), added: false };
    case "addTapped": return { ...s, added: true };
  }
}

Tests are one line each: reduce({quantity: 10, added: false}, increment) stays at 10; reduce({quantity: 3, added: true}, decrement) is {2, false}. The actual “add to cart” network call would be an effect started by the store when it sees addTapped.

Production puzzles

Spot the bugThe error toast that comes back

Tap the line with the bug.

@Observable @MainActor
final class CheckoutModel {
    private(set) var state = CheckoutState()

    func payFailed(_ message: String) {
        state.toastMessage = message
    }
}
// View: .onChange(of: model.state.toastMessage) { _, msg in if let msg { showToast(msg) } }
//       and the screen is rebuilt from state when it reappears
data class CheckoutState(val total: Long = 0, val toastMessage: String? = null)

class CheckoutViewModel : ViewModel() {
    private val _state = MutableStateFlow(CheckoutState())
    val state: StateFlow<CheckoutState> = _state

    fun payFailed(message: String) = _state.update { it.copy(toastMessage = message) }
}
// Compose: LaunchedEffect(state.toastMessage) { state.toastMessage?.let { snackbar.show(it) } }
public class CheckoutViewModel extends ViewModel {
    private final MutableLiveData<String> toast = new MutableLiveData<>();
    public LiveData<String> toast() { return toast; }

    public void payFailed(String message) { toast.setValue(message); }
}
// Activity: viewModel.toast().observe(this, msg -> Toast.makeText(this, msg, LENGTH_SHORT).show());
function reduce(state: CheckoutState, intent: CheckoutIntent): CheckoutState {
  switch (intent.type) {
    case "payFailed":
      return { ...state, toastMessage: intent.message };
    default:
      return state;
  }
}
// View: render() shows a toast whenever state.toastMessage is set

Exercisemedium

Choose an architecture

Three projects: (1) a three-screen internal tool built by one developer in two weeks, (2) a banking app with 60 screens, 25 engineers in five teams, and strict audit requirements on what the user saw, (3) a real-time multiplayer drawing canvas with 60 updates per second. Recommend an approach for each and say what you’d watch out for.

›

Weigh ceremony against benefits: testability, consistency across teams, traceability of state changes, and performance with high-frequency updates.

SolutionTry it yourself first
  1. Internal tool: plain MVVM with the framework’s built-ins (@Observable ViewModels, Android ViewModel + StateFlow, a simple store). No MVI machinery. Watch for: logic leaking into views; keep networking behind one service so it stays testable.
  2. Banking app: MVI / unidirectional flow (TCA on iOS, an MVI base on Android, Redux-style on web) with a shared convention across teams. One state per screen, pure reducers, effects as explicit objects, intents logged (without sensitive data) for audit and debugging, and one-off events handled with consumed IDs. Watch for: boilerplate (provide templates and lint rules), and state granularity on large screens (compose child reducers).
  3. Drawing canvas: MVVM/MVI for the surrounding UI (toolbars, panels), but the canvas itself bypasses the reducer for stroke points: a mutable, purpose-built model (ring buffer of points) rendered directly at frame rate, with reducer-level state only for committed strokes and session events. Watch for: allocation per frame (immutable copies at 60 Hz × many points cause GC/ARC churn) and reconciling local strokes with server state.

The general answer interviewers want: the pattern is a tool for a specific team size, lifetime and update pattern, not a badge.

  • MVI: one immutable state, intents, pure reducer, explicit effects whose results come back as intents; easy to test and trace, more ceremony.
  • One-off events (toasts, navigation) aren’t state: consume them by id through an intent, or deliver as a non-replayed stream with buffering care.
  • Choose by team size, app lifetime, audit/trace needs and update frequency; high-frequency paths (drawing, scrolling) may bypass the reducer.

Check this part

  1. In MVI, how does the screen's state change?

  2. Does the reducer call the network?

  3. A "Payment failed" toast is kept in state and never cleared. What happens after rotation?

MVI keeps the whole screen in one immutable state; the view sends intents, a pure reducer computes the next state, and effects (network, navigation) run outside it. One-off events like toasts and navigation must not be stored as state, or they replay.

Key takeaways

  • All four patterns separate drawing (View), data and rules (Model), and the screen’s logic in between.
  • MVP’s presenter tells the view what to do; MVVM’s ViewModel exposes state the view observes; MVI uses one state and a reducer.
  • A ViewModel must never reference its view; it exposes state and the view renders it.
  • Change observable state by publishing new values, and don’t store one-off events like toasts as state.
  • The patterns differ in communication direction and state shape; declarative UI favors MVVM/MVI.
  • No UI types in ViewModels; lifecycle-aware collection; immutable observable state.
  • MVI: pure reducers, explicit effects, events consumed by id.
  • Pick by team, lifetime, traceability and update frequency, not fashion.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.