🫢 Quick thing before we dive in: If this article helps you even a little, tapping that πŸ‘ button (you can hit it up to 50 times!) helps it reach more iOS devs. It's a small gesture that makes a big difference. Thanks!

πŸ—οΈ What is MVI Architecture?

Let me be honest with you β€” when I first heard about MVI (Model-View-Intent) architecture, I thought it was just another buzzword in the endless parade of iOS architectural patterns. MVVM was working fine, right? Why complicate things?

But here's the thing… as your SwiftUI apps grow beyond simple screens, you start running into problems. State scattered across multiple @StateObjects, complex binding chains that make your head spin, and debugging sessions where you can't figure out why your UI is in some weird state. Sound familiar?

MVI offers a different approach β€” one that's becoming increasingly popular in the iOS community, especially for complex applications that need predictable state management.

What Exactly is MVI?

MVI stands for Model-View-Intent, and it's all about creating a unidirectional data flow in your application. Think of it as a circle where data flows in only one direction:

  1. User performs an action (Intent)
  2. Intent updates the Model (State)
  3. View reacts to Model changes
  4. Back to step 1

No shortcuts, no backdoors, no "quick fixes" that bypass the flow.

The Three Pillars

Model β€” This isn't your data model like User or Product. In MVI, the Model represents the entire state of your feature or screen. It's a single source of truth that describes everything your UI needs to know.

View β€” Your SwiftUI views become pure presentation layers. They don't manage state, they don't make decisions β€” they just display what the Model tells them and forward user actions as Intents.

Intent β€” These represent user actions and their intentions. Tapping a button, entering text, pulling to refresh β€” these all become Intents that flow through your system.

How's This Different from MVVM?

In MVVM, you might have something like this:

// Traditional MVVM approach
@StateObject private var viewModel = TodoViewModel()

Button("Add Todo") {
    viewModel.addTodo(title) // Direct method call
}

With MVI, the same interaction becomes:

// MVI approach
Button("Add Todo") {
    store.send(.addTodo(title)) // Send an intent
}

The difference might seem subtle, but it's profound. In MVVM, your view talks directly to the ViewModel. In MVI, everything goes through the intent system, creating a predictable, traceable flow.

Why Unidirectional Matters

Picture this: you're debugging a complex screen where the save button sometimes stays disabled even when all fields are filled. In traditional patterns, that state could be coming from anywhere β€” multiple ViewModels, direct view state, binding chains, side effects…

With MVI's unidirectional flow, you can trace exactly what happened:

  1. User filled the last field β†’ fieldChanged intent
  2. Intent updated the model with new field value
  3. Model recalculated canSave property
  4. View received new state and updated button

One path, predictable flow, easier debugging.

Ready to dive deeper into how these components actually work together? Let's break down each piece in the next section.

πŸ” Understanding the Components

Now that we've got the big picture, let's break down each component and see how they actually work in practice. I'll be honest β€” when I first tried to implement MVI, I kept trying to force my MVVM thinking into it. Big mistake. Each component has a very specific role, and understanding that is crucial.

The Model: Your Single Source of Truth

In MVI, the Model isn't a single object β€” it's the complete state of your feature. Think of it as a snapshot of everything your UI needs to know at any given moment.

struct TodoState {
    var todos: [Todo] = []
    var isLoading: Bool = false
    var errorMessage: String? = nil
    var newTodoText: String = ""
    var filter: TodoFilter = .all
    
    // Computed properties for derived state
    var filteredTodos: [Todo] {
        switch filter {
        case .all: return todos
        case .completed: return todos.filter(\.isCompleted)
        case .pending: return todos.filter { !$0.isCompleted }
        }
    }
    
    var canAddTodo: Bool {
        !newTodoText.isEmpty && !isLoading
    }
}

See what's happening here? Everything the UI needs is in one place. No scattered state, no "where did I put that loading flag?" moments. The state is immutable β€” you never modify it directly, you create new versions of it.

Intents: The Language of User Actions

Intents represent every possible thing a user can do in your feature. They're like a contract between your UI and your business logic.

enum TodoIntent {
    case loadTodos
    case addTodo(String)
    case toggleTodo(UUID)
    case deleteTodo(UUID)
    case updateNewTodoText(String)
    case changeFilter(TodoFilter)
    case clearError
}

Here's what I love about this approach β€” just by looking at your intents, you know exactly what your feature can do. No hidden methods, no surprise side effects. It's like documentation that can't get out of sync.

The View: Pure and Simple

Your SwiftUI views become incredibly simple. They observe the state and send intents. That's it.

struct TodoView: View {
    @StateObject private var store = TodoStore()
    
    var body: some View {
        VStack {
            if store.state.isLoading {
                ProgressView("Loading todos...")
            } else {
                todoList
                addTodoSection
            }
        }
        .onAppear {
            store.send(.loadTodos)
        }
    }
    
    private var todoList: some View {
        List(store.state.filteredTodos) { todo in
            TodoRow(todo: todo) {
                store.send(.toggleTodo(todo.id))
            }
        }
    }
    
    private var addTodoSection: some View {
        HStack {
            TextField("New todo", text: .constant(store.state.newTodoText))
                .onChange(of: store.state.newTodoText) { _, newValue in
                    store.send(.updateNewTodoText(newValue))
                }
            
            Button("Add") {
                store.send(.addTodo(store.state.newTodoText))
            }
            .disabled(!store.state.canAddTodo)
        }
    }
}

Notice how the view doesn't make any decisions? It just displays state and forwards intents. All the logic lives elsewhere.

The Store: Where the Magic Happens

The Store is where your intents get processed and your state gets updated. It's the brain of your MVI setup.

@MainActor
class TodoStore: ObservableObject {
    @Published private(set) var state = TodoState()
    
    func send(_ intent: TodoIntent) {
        switch intent {
        case .loadTodos:
            handleLoadTodos()
            
        case .addTodo(let text):
            handleAddTodo(text)
            
        case .toggleTodo(let id):
            handleToggleTodo(id)
            
        case .updateNewTodoText(let text):
            state = TodoState(
                todos: state.todos,
                isLoading: state.isLoading,
                errorMessage: state.errorMessage,
                newTodoText: text,
                filter: state.filter
            )
            
        // ... other cases
        }
    }
    
    private func handleLoadTodos() {
        state = state.copy(isLoading: true, errorMessage: nil)
        
        Task {
            do {
                let todos = try await TodoService.shared.fetchTodos()
                state = state.copy(todos: todos, isLoading: false)
            } catch {
                state = state.copy(isLoading: false, errorMessage: error.localizedDescription)
            }
        }
    }
}

The Store receives intents, decides what to do with them, and creates new state. Every state change is intentional and traceable.

The Flow in Action

Let's trace through what happens when a user taps "Add Todo":

  1. View sends addTodo("Buy milk") intent
  2. Store receives the intent in its send() method
  3. Store processes the intent, maybe calls an API
  4. Store creates new state with the added todo
  5. View automatically re-renders because it's observing the state

One direction, no shortcuts, completely predictable.

The beauty is that this flow is the same whether you're adding a todo, handling an error, or managing complex async operations. The pattern stays consistent.

Ready to see how we actually build this from scratch? Next up, we'll create your first MVI setup step by step.

πŸŽ₯ Watch the Video Tutorial

Prefer learning by watching? I've created a comprehensive video tutorial that walks through this entire implementation:

▢️ Watch on YouTube: MVI Architecture in SwiftUI β€” Complete Guide

The video covers everything in this article plus live code walkthroughs and practical examples. Perfect if you want to see the implementation in action!

πŸ› οΈ Building Your First MVI Setup

Alright, let's get our hands dirty and build this thing from the ground up. I'm going to walk you through creating a basic MVI architecture that you can adapt for any SwiftUI project. We'll start simple and build up the complexity.

Step 1: Define Your State

First things first β€” we need to define what our state looks like. For our example, let's build a simple counter app (I know, I know, but it's perfect for understanding the pattern).

struct CounterState {
    let count: Int
    let isLoading: Bool
    let lastOperation: String?
    
    init(count: Int = 0, isLoading: Bool = false, lastOperation: String? = nil) {
        self.count = count
        self.isLoading = isLoading
        self.lastOperation = lastOperation
    }
}

// Helper for creating new state instances
extension CounterState {
    func copy(
        count: Int? = nil,
        isLoading: Bool? = nil,
        lastOperation: String? = nil
    ) -> CounterState {
        CounterState(
            count: count ?? self.count,
            isLoading: isLoading ?? self.isLoading,
            lastOperation: lastOperation ?? self.lastOperation
        )
    }
}

Notice how everything is immutable? We never modify the state directly β€” we always create new instances. That copy method is a little helper that makes creating new state less verbose.

Step 2: Define Your Intents

Next, let's define all the possible actions our counter can handle:

enum CounterIntent {
    case increment
    case decrement
    case reset
    case asyncIncrement // For demonstrating async operations
    case clearLastOperation
}

These intents represent every single thing a user can do with our counter. Clean and explicit.

Step 3: Create the Store Protocol

Now here's where we set up the foundation. I like to create a protocol that all my stores conform to:

protocol MVIStore: ObservableObject {
    associatedtype State
    associatedtype Intent
    
    var state: State { get }
    func send(_ intent: Intent)
}

This gives us a consistent interface across all our MVI stores. Any store that conforms to this protocol will have a state and a way to send intents.

Step 4: Implement the Counter Store

Here's where the real magic happens. Our store processes intents and updates state:

@MainActor
final class CounterStore: MVIStore {
    @Published private(set) var state = CounterState()
    
    func send(_ intent: CounterIntent) {
        switch intent {
        case .increment:
            handleIncrement()
            
        case .decrement:
            handleDecrement()
            
        case .reset:
            handleReset()
            
        case .asyncIncrement:
            handleAsyncIncrement()
            
        case .clearLastOperation:
            handleClearLastOperation()
        }
    }
    
    // MARK: - Intent Handlers
    
    private func handleIncrement() {
        state = state.copy(
            count: state.count + 1,
            lastOperation: "Incremented to \(state.count + 1)"
        )
    }
    
    private func handleDecrement() {
        state = state.copy(
            count: state.count - 1,
            lastOperation: "Decremented to \(state.count - 1)"
        )
    }
    
    private func handleReset() {
        state = state.copy(
            count: 0,
            lastOperation: "Reset to 0"
        )
    }
    
    private func handleAsyncIncrement() {
        // First, show loading state
        state = state.copy(
            isLoading: true,
            lastOperation: "Processing async increment..."
        )
        
        // Simulate async operation
        Task {
            try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
            
            state = state.copy(
                count: state.count + 1,
                isLoading: false,
                lastOperation: "Async incremented to \(state.count + 1)"
            )
        }
    }
    
    private func handleClearLastOperation() {
        state = state.copy(lastOperation: nil)
    }
}

See how each intent has its own handler method? This keeps things organized and makes it easy to understand what each intent does. The async example shows how you handle side effects β€” update state to show loading, perform the async work, then update state again with the result.

Step 5: Connect to SwiftUI

Finally, let's create our view that uses this store:

struct CounterView: View {
    @StateObject private var store = CounterStore()
    
    var body: some View {
        VStack(spacing: 20) {
            // Display current count
            Text("\(store.state.count)")
                .font(.largeTitle)
                .fontWeight(.bold)
            
            // Show last operation if available
            if let lastOperation = store.state.lastOperation {
                Text(lastOperation)
                    .font(.caption)
                    .foregroundColor(.secondary)
                    .onTapGesture {
                        store.send(.clearLastOperation)
                    }
            }
            
            // Action buttons
            HStack(spacing: 15) {
                Button("-") {
                    store.send(.decrement)
                }
                .buttonStyle(.bordered)
                
                Button("+") {
                    store.send(.increment)
                }
                .buttonStyle(.bordered)
                
                Button("Reset") {
                    store.send(.reset)
                }
                .buttonStyle(.bordered)
            }
            
            // Async increment button with loading state
            Button(action: {
                store.send(.asyncIncrement)
            }) {
                HStack {
                    if store.state.isLoading {
                        ProgressView()
                            .scaleEffect(0.8)
                    }
                    Text("Async +1")
                }
            }
            .disabled(store.state.isLoading)
            .buttonStyle(.borderedProminent)
        }
        .padding()
    }
}

Look at how clean this view is! It just displays state and sends intents. No business logic, no state management β€” just pure presentation.

The Complete Flow

Let's trace what happens when you tap the "+" button:

  1. User taps "+" β†’ View sends .increment intent
  2. Store receives intent in send() method
  3. Store calls handleIncrement()
  4. Handler creates new state with count + 1
  5. @Published state triggers UI update
  6. View re-renders with new count

Every interaction follows this exact same pattern. Predictable, traceable, and easy to debug.

Quick Tips for Your First MVI Setup

Keep intents pure β€” They should just describe what the user wants to do, not how to do it.

Make state immutable β€” Always create new state instances rather than modifying existing ones.

Handle async carefully β€” Always update state to show loading, then update again with results.

One store per feature β€” Don't try to put your entire app in one massive store.

Ready to see this pattern applied to something more realistic? Next, we'll build a complete Todo app that shows how MVI handles real-world complexity.

πŸ“± Real-World Example: Todo App

Now let's build something more realistic β€” a Todo app that demonstrates how MVI handles complex state, async operations, and real user workflows. This is where MVI really starts to shine compared to simpler patterns.

Step 1: Define the Todo Models

First, let's set up our basic data models:

struct Todo: Identifiable, Codable, Equatable {
    let id: UUID
    var title: String
    var isCompleted: Bool
    let createdAt: Date
    
    init(title: String, isCompleted: Bool = false) {
        self.id = UUID()
        self.title = title
        self.isCompleted = isCompleted
        self.createdAt = Date()
    }
    
    // Custom init for decoding
    init(id: UUID, title: String, isCompleted: Bool, createdAt: Date) {
        self.id = id
        self.title = title
        self.isCompleted = isCompleted
        self.createdAt = createdAt
    }
}

enum TodoFilter: CaseIterable {
    case all, active, completed
    
    var title: String {
        switch self {
        case .all: return "All"
        case .active: return "Active"
        case .completed: return "Completed"
        }
    }
}

Step 2: Design the Complete State

Here's where MVI gets interesting. We need to think about every possible state our Todo feature can be in:

struct TodoState {
    // Core data
    var todos: [Todo] = []
    var newTodoText: String = ""
    var currentFilter: TodoFilter = .all
    
    // UI states
    var isLoading: Bool = false
    var isAddingTodo: Bool = false
    
    // Editing state
    var editingTodoId: UUID? = nil
    var editingText: String = ""
    
    // Computed properties for derived state
    var filteredTodos: [Todo] {
        switch currentFilter {
        case .all:
            return todos
        case .active:
            return todos.filter { !$0.isCompleted }
        case .completed:
            return todos.filter { $0.isCompleted }
        }
    }
    
    var canAddTodo: Bool {
        !newTodoText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isAddingTodo
    }
    
    var activeTodoCount: Int {
        todos.filter { !$0.isCompleted }.count
    }
    
    var completedTodoCount: Int {
        todos.filter { $0.isCompleted }.count
    }
    
    var isEmpty: Bool {
        todos.isEmpty
    }
}

// Helper for immutable updates
extension TodoState {
    func copy(
        todos: [Todo]? = nil,
        newTodoText: String? = nil,
        currentFilter: TodoFilter? = nil,
        isLoading: Bool? = nil,
        isAddingTodo: Bool? = nil,
        editingTodoId: UUID? = nil,
        editingText: String? = nil
    ) -> TodoState {
        var newState = self
        if let todos = todos { newState.todos = todos }
        if let newTodoText = newTodoText { newState.newTodoText = newTodoText }
        if let currentFilter = currentFilter { newState.currentFilter = currentFilter }
        if let isLoading = isLoading { newState.isLoading = isLoading }
        if let isAddingTodo = isAddingTodo { newState.isAddingTodo = isAddingTodo }
        if let editingTodoId = editingTodoId { newState.editingTodoId = editingTodoId }
        if let editingText = editingText { newState.editingText = editingText }
        return newState
    }
}

Notice how we're thinking about everything β€” loading states, editing states, even an empty state. This is the beauty of MVI β€” you're forced to think about all possible states upfront.

Step 3: Define All Possible Intents

Now let's map out every single thing a user can do:

enum TodoIntent {
    // Loading and data management
    case loadTodos
    case refreshTodos
    
    // Adding todos
    case updateNewTodoText(String)
    case addTodo
    
    // Todo operations
    case toggleTodo(UUID)
    case deleteTodo(UUID)
    case clearCompleted
    
    // Editing
    case startEditing(UUID)
    case updateEditingText(String)
    case saveEdit
    case cancelEdit
    
    // Filtering
    case changeFilter(TodoFilter)
}

This is like a contract β€” if it's not in this enum, your app can't do it. No surprise features or hidden state changes.

Step 4: Create the Storage Service

Here's our simple AppStorage-based service that starts with an empty list:

@MainActor
class TodoService {
    static let shared = TodoService()
    private init() {}
    
    @AppStorage("todos_data") private var todosData: Data = Data()
    
    private var todos: [Todo] {
        get {
            guard !todosData.isEmpty else { 
                return [] // Start with empty list
            }
            
            do {
                return try JSONDecoder().decode([Todo].self, from: todosData)
            } catch {
                print("Failed to decode todos: \(error)")
                return []
            }
        }
        set {
            do {
                todosData = try JSONEncoder().encode(newValue)
            } catch {
                print("Failed to encode todos: \(error)")
            }
        }
    }
    
    func loadTodos() async -> [Todo] {
        // Small delay to show loading state in UI
        try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
        return todos
    }
    
    func addTodo(_ todo: Todo) async -> Todo {
        var currentTodos = todos
        currentTodos.append(todo)
        todos = currentTodos
        return todo
    }
    
    func updateTodo(_ todo: Todo) async -> Todo {
        var currentTodos = todos
        if let index = currentTodos.firstIndex(where: { $0.id == todo.id }) {
            currentTodos[index] = todo
        }
        todos = currentTodos
        return todo
    }
    
    func deleteTodo(id: UUID) async {
        var currentTodos = todos
        currentTodos.removeAll { $0.id == id }
        todos = currentTodos
    }
    
    func clearCompleted() async {
        var currentTodos = todos
        currentTodos.removeAll { $0.isCompleted }
        todos = currentTodos
    }
}

Perfect! Now if you delete all todos, they stay deleted. No fake data coming back to haunt you.

Step 5: Implement the Todo Store

Here's where all the magic happens. This store handles every intent and manages all state transitions:

@MainActor
final class TodoStore: MVIStore {
    @Published private(set) var state = TodoState()
    private let todoService = TodoService.shared

    // MARK: - Binding Properties
    var newTodoTextBinding: Binding<String> {
        Binding(
            get: { self.state.newTodoText },
            set: { self.send(.updateNewTodoText($0)) }
        )
    }

    var filterBinding: Binding<TodoFilter> {
        Binding(
            get: { self.state.currentFilter },
            set: { self.send(.changeFilter($0)) }
        )
    }

    var editingTextBinding: Binding<String> {
        Binding(
            get: { self.state.editingText },
            set: { self.send(.updateEditingText($0)) }
        )
    }

    func send(_ intent: TodoIntent) {
        switch intent {
        case .loadTodos:
            handleLoadTodos()
        case .refreshTodos:
            handleRefreshTodos()
        case .updateNewTodoText(let text):
            handleUpdateNewTodoText(text)
        case .addTodo:
            handleAddTodo()
        case .toggleTodo(let id):
            handleToggleTodo(id)
        case .deleteTodo(let id):
            handleDeleteTodo(id)
        case .clearCompleted:
            handleClearCompleted()
        case .startEditing(let id):
            handleStartEditing(id)
        case .updateEditingText(let text):
            handleUpdateEditingText(text)
        case .saveEdit:
            handleSaveEdit()
        case .cancelEdit:
            handleCancelEdit()
        case .changeFilter(let filter):
            handleChangeFilter(filter)
        }
    }
    
    // MARK: - Loading and Data Management
    
    private func handleLoadTodos() {
        guard !state.isLoading else { return }
        
        state = state.copy(isLoading: true)
        
        Task { @MainActor in
            let todos = await todoService.loadTodos()
            state = state.copy(todos: todos, isLoading: false)
        }
    }
    
    private func handleRefreshTodos() {
        Task { @MainActor in
            let todos = await todoService.loadTodos()
            state = state.copy(todos: todos)
        }
    }
    
    // MARK: - Adding Todos
    
    private func handleUpdateNewTodoText(_ text: String) {
        state = state.copy(newTodoText: text)
    }
    
    private func handleAddTodo() {
        guard state.canAddTodo else { return }
        
        let todoText = state.newTodoText.trimmingCharacters(in: .whitespacesAndNewlines)
        let newTodo = Todo(title: todoText)
        
        state = state.copy(isAddingTodo: true)
        
        Task { @MainActor in
            let addedTodo = await todoService.addTodo(newTodo)
            var updatedTodos = state.todos
            updatedTodos.append(addedTodo)
            
            state = state.copy(
                todos: updatedTodos,
                newTodoText: "",
                isAddingTodo: false
            )
        }
    }
    
    // MARK: - Todo Operations
    
    private func handleToggleTodo(_ id: UUID) {
        guard let todoIndex = state.todos.firstIndex(where: { $0.id == id }) else { return }
        
        var updatedTodo = state.todos[todoIndex]
        updatedTodo.isCompleted.toggle()
        
        var updatedTodos = state.todos
        updatedTodos[todoIndex] = updatedTodo
        
        // Update UI immediately
        state = state.copy(todos: updatedTodos)
        
        Task { @MainActor in
            _ = await todoService.updateTodo(updatedTodo)
        }
    }
    
    private func handleDeleteTodo(_ id: UUID) {
        let updatedTodos = state.todos.filter { $0.id != id }
        
        // Update UI immediately
        state = state.copy(todos: updatedTodos)
        
        Task { @MainActor in
            await todoService.deleteTodo(id: id)
        }
    }
    
    private func handleClearCompleted() {
        Task { @MainActor in
            await todoService.clearCompleted()
            let todos = await todoService.loadTodos()
            state = state.copy(todos: todos)
        }
    }
    
    // MARK: - Editing
    
    private func handleStartEditing(_ id: UUID) {
        guard let todo = state.todos.first(where: { $0.id == id }) else { return }
        state = state.copy(editingTodoId: id, editingText: todo.title)
    }
    
    private func handleUpdateEditingText(_ text: String) {
        state = state.copy(editingText: text)
    }
    
    private func handleSaveEdit() {
        guard let editingId = state.editingTodoId,
              let todoIndex = state.todos.firstIndex(where: { $0.id == editingId }) else { return }
        
        var updatedTodo = state.todos[todoIndex]
        updatedTodo.title = state.editingText.trimmingCharacters(in: .whitespacesAndNewlines)
        
        var updatedTodos = state.todos
        updatedTodos[todoIndex] = updatedTodo
        
        state = state.copy(
            todos: updatedTodos,
            editingTodoId: nil,
            editingText: ""
        )
        
        Task { @MainActor in
            _ = await todoService.updateTodo(updatedTodo)
        }
    }
    
    private func handleCancelEdit() {
        state = state.copy(editingTodoId: nil, editingText: "")
    }
    
    // MARK: - Filtering
    
    private func handleChangeFilter(_ filter: TodoFilter) {
        state = state.copy(currentFilter: filter)
    }
}

Look at how each intent handler is focused on one specific thing. The async operations update the UI immediately for great user experience, then persist the changes in the background.

This setup is clean, predictable, and your data persists perfectly between app launches. No fake data surprises!

Step 6: Build the SwiftUI Views

Here's how our MVI architecture comes together in the UI. Notice how clean and focused each view becomes:

struct TodoListView: View {
    @StateObject private var store = TodoStore()
    
    var body: some View {
        NavigationView {
            VStack {
                if store.state.isLoading {
                    loadingView
                } else if store.state.isEmpty {
                    emptyStateView
                } else {
                    todoContent
                }
            }
            .navigationTitle("My Todos")
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    if store.state.completedTodoCount > 0 {
                        Button("Clear Completed") {
                            store.send(.clearCompleted)
                        }
                    }
                }
            }
            .onAppear {
                store.send(.loadTodos)
            }
        }
    }
    
    private var loadingView: some View {
        VStack {
            ProgressView()
            Text("Loading todos...")
                .foregroundColor(.secondary)
        }
    }
    
    private var emptyStateView: some View {
        VStack(spacing: 20) {
            Image(systemName: "checkmark.circle")
                .font(.largeTitle)
                .foregroundColor(.secondary)
            
            Text("No todos yet")
                .font(.headline)
            
            Text("Add your first todo below to get started!")
                .foregroundColor(.secondary)
                .multilineTextAlignment(.center)
            
            addTodoSection
        }
        .padding()
    }
    
    private var todoContent: some View {
        VStack {
            filterSection
            todoList
            addTodoSection
            statsSection
        }
    }
    
    private var filterSection: some View {
        Picker("Filter", selection: store.filterBinding) {
            ForEach(TodoFilter.allCases, id: \.self) { filter in
                Text(filter.title).tag(filter)
            }
        }
        .pickerStyle(.segmented)
        .padding(.horizontal)
    }
    
    private var todoList: some View {
        List {
            ForEach(store.state.filteredTodos) { todo in
                TodoRowView(
                    todo: todo,
                    isEditing: store.state.editingTodoId == todo.id,
                    editingTextBinding: store.editingTextBinding,
                    onToggle: { store.send(.toggleTodo(todo.id)) },
                    onDelete: { store.send(.deleteTodo(todo.id)) },
                    onStartEdit: { store.send(.startEditing(todo.id)) },
                    onSaveEdit: { store.send(.saveEdit) },
                    onCancelEdit: { store.send(.cancelEdit) }
                )
            }
        }
        .refreshable {
            store.send(.refreshTodos)
        }
    }
    
    private var addTodoSection: some View {
        HStack {
            TextField("What needs to be done?", text: store.newTodoTextBinding)
                .textFieldStyle(.roundedBorder)
                .onSubmit {
                    if store.state.canAddTodo {
                        store.send(.addTodo)
                    }
                }
            
            Button(action: { store.send(.addTodo) }) {
                if store.state.isAddingTodo {
                    ProgressView()
                        .scaleEffect(0.8)
                } else {
                    Image(systemName: "plus.circle.fill")
                        .font(.title2)
                }
            }
            .disabled(!store.state.canAddTodo)
        }
        .padding()
    }
    
    private var statsSection: some View {
        HStack {
            Text("\(store.state.activeTodoCount) active")
            Spacer()
            Text("\(store.state.completedTodoCount) completed")
        }
        .font(.caption)
        .foregroundColor(.secondary)
        .padding(.horizontal)
    }
}

Now here's the individual todo row component:

struct TodoRowView: View {
    let todo: Todo
    let isEditing: Bool
    let editingTextBinding: Binding<String>
    
    let onToggle: () -> Void
    let onDelete: () -> Void
    let onStartEdit: () -> Void
    let onSaveEdit: () -> Void
    let onCancelEdit: () -> Void
    
    var body: some View {
        HStack {
            // Completion toggle
            Button(action: onToggle) {
                Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
                    .foregroundColor(todo.isCompleted ? .green : .gray)
                    .font(.title2)
            }
            .buttonStyle(.plain)
            
            // Todo content
            if isEditing {
                editingView
            } else {
                displayView
            }
        }
        .padding(.vertical, 4)
        .swipeActions(edge: .trailing, allowsFullSwipe: true) {
            Button("Delete", role: .destructive, action: onDelete)
        }
        .swipeActions(edge: .leading) {
            Button("Edit", action: onStartEdit)
                .tint(.blue)
        }
    }
    
    private var displayView: some View {
        HStack {
            VStack(alignment: .leading, spacing: 2) {
                Text(todo.title)
                    .strikethrough(todo.isCompleted)
                    .foregroundColor(todo.isCompleted ? .secondary : .primary)
                
                Text(todo.createdAt, style: .date)
                    .font(.caption2)
                    .foregroundColor(.secondary)
            }
            
            Spacer()
        }
        .contentShape(Rectangle())
        .onTapGesture {
            onStartEdit()
        }
    }
    
    private var editingView: some View {
        HStack {
            TextField("Todo title", text: editingTextBinding)
                .textFieldStyle(.roundedBorder)
                .onSubmit(onSaveEdit)
            
            Button("Save", action: onSaveEdit)
                .buttonStyle(.borderedProminent)
                .controlSize(.small)
            
            Button("Cancel", action: onCancelEdit)
                .buttonStyle(.bordered)
                .controlSize(.small)
        }
    }
}

What Makes This MVI?

Look at what's happening in these views:

1. Pure Presentation β€” Views only display state and send intents. No business logic.

2. Single Source of Truth β€” Everything the UI needs comes from store.state.

3. Unidirectional Flow β€” User interactions become intents that flow through the store.

4. Predictable Updates β€” Every state change is traceable through the intent system.

The view doesn't know or care how todos are stored, how they're filtered, or what happens when you toggle completion. It just sends intents and displays state.

πŸ“Š When to Use MVI vs Other Patterns

Alright, let's be honest here. MVI isn't a silver bullet, and I've seen teams adopt it just because it sounds fancy, only to regret it later. So when should you actually use MVI?

When MVI Shines ✨

Complex State Management If you're dealing with screens that have multiple data sources, real-time updates, and intricate state dependencies, MVI is your friend. Think of a dashboard with filters, live data, user preferences, and multiple async operations happening simultaneously.

Team Collaboration MVI really shines when you have multiple developers working on the same features. The explicit intent system makes it crystal clear what each feature can do, and the unidirectional flow makes debugging much easier when someone else's code breaks.

Predictable User Flows Apps with complex user workflows β€” like e-commerce checkouts, multi-step forms, or wizard-like interfaces β€” benefit massively from MVI's predictable state transitions.

Heavy Testing Requirements If you need comprehensive testing (think fintech, healthcare, or enterprise apps), MVI's separation of concerns makes unit testing a breeze. Every intent can be tested in isolation.

When MVI Might Be Overkill 🎯

Simple CRUD Apps If you're building a straightforward app with basic list views, simple forms, and minimal state interactions, MVI adds unnecessary complexity. MVVM or even plain SwiftUI with @State might be more appropriate.

Prototype or MVP Development When you need to move fast and prove concepts quickly, MVI's upfront architectural investment might slow you down. Start simple, refactor later if needed.

Small Teams or Solo Development If it's just you or a very small team, the communication benefits of explicit intents might not outweigh the additional boilerplate.

MVI vs MVVM: The Real Talk πŸ€”

MVVM is better when:

  • You want faster development cycles
  • Your app has straightforward data flows
  • You're comfortable with SwiftUI's binding patterns
  • Your team is already productive with MVVM

MVI is better when:

  • You need predictable, traceable state changes
  • Multiple developers work on complex features
  • You have intricate async operations and side effects
  • Testing and maintainability are top priorities

The Migration Path πŸ›€οΈ

Here's what I've learned from real projects: you don't have to go all-in immediately.

Start with MVI for your most complex screens β€” the ones that give you headaches in MVVM. Keep simpler screens in MVVM. As your team gets comfortable with the pattern, gradually migrate more features.

This hybrid approach lets you get the benefits where you need them most without rewriting your entire app.

Making the Decision 🎭

Ask yourself these questions:

  1. Do I spend significant time debugging state issues? If yes, MVI might help.
  2. Are multiple developers touching the same complex features? MVI's explicit contracts reduce conflicts.
  3. Do I have complex async workflows with multiple interdependent operations? MVI handles this elegantly.
  4. Is my current architecture making testing difficult? MVI's separation makes testing much easier.
  5. Am I building for long-term maintainability? MVI pays dividends over time.

If you answered "yes" to 3 or more, MVI is probably worth the investment.

The Bottom Line πŸ“

MVI isn't about being trendy or following the latest architectural fad. It's a tool that solves specific problems around predictable state management and complex user interactions.

Use it when those problems exist, skip it when they don't. Your future self (and your teammates) will thank you for making the pragmatic choice rather than the fashionable one.

The best architecture is the one that helps your team ship quality software efficiently. Sometimes that's MVI, sometimes it's not. And that's perfectly fine.

πŸŽ‰ Enjoyed this article? Your support means the world to me!

πŸ‘ Give it some claps if it helped you out β€” it really helps other developers discover this content

πŸ’¬ Drop a comment below! I love hearing about your experiences and answering questions

🎬 Subscribe on Youtube and become early subscribers of my channel: https://www.youtube.com/@swift-pal

🐦 Follow me on Twitter for daily SwiftUI tips and tricks: https://twitter.com/swift_karan

πŸ’Ό Let's connect on LinkedIn for more professional insights: https://www.linkedin.com/in/karan-pal

πŸ“¬ Subscribe to my Medium so you never miss my latest deep dives: https://medium.com/@karan.pal/subscribe

β˜• Buy me a coffee if this saved you some debugging time (seriously, your support keeps me writing!): https://coff.ee/karanpaledx

Happy coding! πŸš€