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.
| Pattern | The middle piece | How the view gets updates | Who references whom |
|---|---|---|---|
| MVC (Apple-style) | Controller | Controller sets view properties directly | Controller owns view and model |
| MVP | Presenter | Presenter calls methods on a View interface | Presenter → View interface; View → Presenter |
| MVVM | ViewModel | View observes properties the ViewModel exposes | View → ViewModel only |
| MVI | Store / ViewModel with a reducer | View observes one immutable state; sends intents | View → 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
- Mark each line of the screen above: drawing, validation, networking, storage, error messages, navigation.
- Drawing stays in the view: the fields, the button, showing the error text.
- 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.
- 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.
- 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
What should stay in the View?
What's the main benefit of moving logic out of the view?
Which pattern has the view observe a single immutable state and send intents?