MVVM Pattern for Mobile Development

By Ishan Khanna LinkedInUpdated May 25, 2026
mobile-system-designarchitecturemvvmiosandroiddesign-patterns

Every mobile system design interview I have been part of — on either side of the table — eventually hits the same question: "What architecture pattern would you use here, and why?"

And almost every candidate says "MVVM." That part is fine. The problem is what comes next. Most people draw three boxes on a whiteboard, label them Model, View, and ViewModel, draw some arrows, and then freeze. They can't explain why MVVM fits their specific problem, what trade-offs they are accepting, or how data actually flows through the system under real conditions like network failures or configuration changes.

I want to fix that. This article is the guide I wish I'd had before my loops at Google and Airbnb. Not textbook definitions — practical understanding you can use in a live interview.

What MVVM Actually Is#

MVVM stands for Model-View-ViewModel. Three layers, each with a single job:

  • Model — your data and business logic. API responses, database entities, domain objects, and the rules that govern them. The Model has no idea a screen exists.
  • View — what the user sees and interacts with. Activities, Fragments, SwiftUI Views, UIViewControllers. The View is dumb on purpose. It renders state and forwards user actions. That's it.
  • ViewModel — the middleman. It takes raw data from the Model layer, transforms it into something the View can render directly, and exposes that transformed state. It also handles user actions by calling into the Model layer.

The key insight is this: the View observes the ViewModel. It doesn't ask for data. It subscribes to changes and reacts when the ViewModel's state updates. This is what separates MVVM from MVC and MVP.

Data Flow: A User Profile Screen#

Let's make it concrete. Imagine you're building a user profile screen — the kind you'd see in Instagram or Twitter.

User taps "Profile" tab
    → View tells ViewModel: loadProfile(userId)
    → ViewModel calls ProfileRepository.fetchProfile(userId)
    → Repository hits the API (or local cache)
    → API returns UserProfile JSON
    → Repository maps it to a UserProfile model
    → ViewModel transforms it into ProfileScreenState
       (display name, formatted follower count, avatar URL, etc.)
    → ViewModel publishes new state
    → View observes the state change and re-renders

Notice the View never touches the API. It never parses JSON. It never formats a follower count from 1429384 to "1.4M". The ViewModel does all of that. The View just receives a ready-to-display state and puts it on screen.

This separation might feel like extra work for a simple profile screen. It is. The payoff comes when you need to test that formatting logic without launching a simulator, or when a designer changes the layout and you don't have to touch a single line of business logic.

Why MVVM Won#

MVVM didn't appear out of nowhere. It evolved because the alternatives had real, painful problems.

The MVC Problem#

Apple's recommended MVC pattern for iOS put the Controller (UIViewController) in charge of way too much. It handled view lifecycle, layout, user interaction, networking callbacks, data formatting, navigation — everything. Engineers started calling it "Massive View Controller" and the joke stuck because it was true. I've worked on codebases where a single UIViewController was 3,000+ lines.

Android had a similar story. Activities and Fragments were simultaneously controllers and views. They managed UI, handled lifecycle callbacks, coordinated data loading, and dealt with configuration changes like screen rotation. Rotating your phone would destroy and recreate the Activity, killing any in-flight network requests. The lifecycle was so complex that Google eventually published a diagram that looked like a state machine gone wrong.

What MVVM Fixed#

MVVM addressed two specific problems:

  1. Separation of presentation logic from the View. All the data formatting, state management, and user action handling moved into the ViewModel. The View became thin. UIViewControllers shrank from thousands of lines to hundreds.

  2. Surviving lifecycle changes. On Android, the Jetpack ViewModel is scoped to a lifecycle owner (Activity or Fragment) but survives configuration changes. Rotate the phone, and your ViewModel — along with all its state — stays alive. This was a game-changer.

On iOS, SwiftUI's declarative model made MVVM feel completely natural. The View declares what it wants to display based on state. The ViewModel holds and manages that state. The framework handles the rest.

MVVM in Practice#

Both platforms' modern UI frameworks were practically designed for MVVM. On iOS, SwiftUI's @Published property wrapper, ObservableObject protocol, and @StateObject/@ObservedObject property wrappers create a reactive binding system with almost no boilerplate. On Android, the Jetpack ViewModel was built specifically for this pattern: it survives configuration changes (screen rotation, language change, dark mode toggle) because it's scoped to the Activity or Fragment lifecycle but not destroyed on onDestroy() during configuration changes.

Let's build a realistic example — a user profile screen, not a counter app — and follow the same code on both platforms.

The Model and ViewModel#

We start with the Model — a plain data type the repository maps API responses into — and the ViewModel that transforms it into ready-to-display state:

// Model
data class UserProfile(
    val id: String,
    val displayName: String,
    val username: String,
    val avatarUrl: String?,
    val bio: String,
    val followerCount: Int,
    val followingCount: Int,
    val postCount: Int,
    val isVerified: Boolean
)

// ViewModel
class ProfileViewModel(
    private val userId: String,
    private val profileRepository: ProfileRepository
) : ViewModel() {

    private val _state = MutableStateFlow<ProfileState>(ProfileState.Loading)
    val state: StateFlow<ProfileState> = _state.asStateFlow()

    init {
        loadProfile()
    }

    fun loadProfile() {
        viewModelScope.launch {
            _state.value = ProfileState.Loading
            try {
                val profile = profileRepository.fetchProfile(userId)
                _state.value = ProfileState.Loaded(profile.toDisplayData())
            } catch (e: Exception) {
                _state.value = ProfileState.Error(e.message ?: "Something went wrong")
            }
        }
    }

    fun toggleFollow() {
        val currentState = _state.value
        if (currentState !is ProfileState.Loaded) return

        _state.value = currentState.copy(
            data = currentState.data.copy(isFollowActionInProgress = true)
        )

        viewModelScope.launch {
            try {
                val updated = profileRepository.toggleFollow(userId)
                _state.value = ProfileState.Loaded(updated.toDisplayData())
            } catch (e: Exception) {
                _state.value = currentState
            }
        }
    }
}

sealed class ProfileState {
    data object Loading : ProfileState()
    data class Loaded(val data: ProfileDisplayData) : ProfileState()
    data class Error(val message: String) : ProfileState()
}

data class ProfileDisplayData(
    val displayName: String,
    val username: String,
    val avatarUrl: String?,
    val bio: String,
    val formattedFollowers: String,
    val formattedFollowing: String,
    val formattedPosts: String,
    val isVerified: Boolean,
    val isFollowActionInProgress: Boolean = false
)

fun UserProfile.toDisplayData() = ProfileDisplayData(
    displayName = displayName,
    username = "@$username",
    avatarUrl = avatarUrl,
    bio = bio,
    formattedFollowers = followerCount.formatCount(),
    formattedFollowing = followingCount.formatCount(),
    formattedPosts = postCount.formatCount(),
    isVerified = isVerified
)

private fun Int.formatCount(): String = when {
    this < 1_000 -> "$this"
    this < 1_000_000 -> String.format("%.1fK", this / 1_000.0)
    else -> String.format("%.1fM", this / 1_000_000.0)
}
// MARK: - Model
struct UserProfile: Codable {
    let id: String
    let displayName: String
    let username: String
    let avatarURL: URL?
    let bio: String
    let followerCount: Int
    let followingCount: Int
    let postCount: Int
    let isVerified: Bool
}

// MARK: - ViewModel
@MainActor
class ProfileViewModel: ObservableObject {
    @Published var state: ProfileState = .loading

    private let profileRepository: ProfileRepository
    private let userId: String

    init(userId: String, profileRepository: ProfileRepository = .shared) {
        self.userId = userId
        self.profileRepository = profileRepository
    }

    func loadProfile() async {
        state = .loading
        do {
            let profile = try await profileRepository.fetchProfile(userId: userId)
            state = .loaded(ProfileDisplayData(from: profile))
        } catch {
            state = .error(error.localizedDescription)
        }
    }

    func toggleFollow() async {
        guard case .loaded(var displayData) = state else { return }
        displayData.isFollowActionInProgress = true
        state = .loaded(displayData)

        do {
            let updatedProfile = try await profileRepository.toggleFollow(userId: userId)
            state = .loaded(ProfileDisplayData(from: updatedProfile))
        } catch {
            displayData.isFollowActionInProgress = false
            state = .loaded(displayData)
        }
    }
}

enum ProfileState {
    case loading
    case loaded(ProfileDisplayData)
    case error(String)
}

struct ProfileDisplayData {
    let displayName: String
    let username: String
    let avatarURL: URL?
    let bio: String
    let formattedFollowers: String
    let formattedFollowing: String
    let formattedPosts: String
    let isVerified: Bool
    var isFollowActionInProgress: Bool = false

    init(from profile: UserProfile) {
        self.displayName = profile.displayName
        self.username = "@\(profile.username)"
        self.avatarURL = profile.avatarURL
        self.bio = profile.bio
        self.formattedFollowers = Self.formatCount(profile.followerCount)
        self.formattedFollowing = Self.formatCount(profile.followingCount)
        self.formattedPosts = Self.formatCount(profile.postCount)
        self.isVerified = profile.isVerified
    }

    private static func formatCount(_ count: Int) -> String {
        switch count {
        case 0..<1_000: return "\(count)"
        case 1_000..<1_000_000: return String(format: "%.1fK", Double(count) / 1_000)
        default: return String(format: "%.1fM", Double(count) / 1_000_000)
        }
    }
}

The shape is identical on both platforms: an exhaustive state type (a sealed class on Android, an enum on iOS) with loading, loaded, and error cases, plus a ProfileDisplayData type that holds pre-formatted strings. The ViewModel owns the state transitions; the Model stays oblivious to the screen.

The View#

Now the View — Compose on Android, SwiftUI on iOS. Either way, its only job is to render the current state and forward user actions:

// View (Compose)
@Composable
fun ProfileScreen(
    viewModel: ProfileViewModel = hiltViewModel()
) {
    val state by viewModel.state.collectAsStateWithLifecycle()

    when (val currentState = state) {
        is ProfileState.Loading -> {
            Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                CircularProgressIndicator()
            }
        }
        is ProfileState.Loaded -> {
            ProfileContent(
                data = currentState.data,
                onFollowTap = { viewModel.toggleFollow() }
            )
        }
        is ProfileState.Error -> {
            ErrorContent(
                message = currentState.message,
                onRetry = { viewModel.loadProfile() }
            )
        }
    }
}

@Composable
private fun ProfileContent(
    data: ProfileDisplayData,
    onFollowTap: () -> Unit
) {
    Column(
        modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Spacer(modifier = Modifier.height(24.dp))

        AsyncImage(
            model = data.avatarUrl,
            contentDescription = "Profile photo",
            modifier = Modifier.size(96.dp).clip(CircleShape),
            contentScale = ContentScale.Crop
        )

        Spacer(modifier = Modifier.height(16.dp))

        Row(verticalAlignment = Alignment.CenterVertically) {
            Text(data.displayName, style = MaterialTheme.typography.headlineSmall)
            if (data.isVerified) {
                Icon(
                    Icons.Default.Verified,
                    contentDescription = "Verified",
                    tint = MaterialTheme.colorScheme.primary,
                    modifier = Modifier.padding(start = 4.dp)
                )
            }
        }

        Text(data.username, color = MaterialTheme.colorScheme.onSurfaceVariant)

        Row(
            modifier = Modifier.padding(vertical = 16.dp),
            horizontalArrangement = Arrangement.spacedBy(32.dp)
        ) {
            StatColumn(value = data.formattedPosts, label = "Posts")
            StatColumn(value = data.formattedFollowers, label = "Followers")
            StatColumn(value = data.formattedFollowing, label = "Following")
        }
    }
}
// MARK: - View
struct ProfileView: View {
    @StateObject private var viewModel: ProfileViewModel

    init(userId: String) {
        _viewModel = StateObject(wrappedValue: ProfileViewModel(userId: userId))
    }

    var body: some View {
        Group {
            switch viewModel.state {
            case .loading:
                ProgressView()
            case .loaded(let data):
                profileContent(data)
            case .error(let message):
                ErrorView(message: message) {
                    Task { await viewModel.loadProfile() }
                }
            }
        }
        .task { await viewModel.loadProfile() }
    }

    @ViewBuilder
    private func profileContent(_ data: ProfileDisplayData) -> some View {
        ScrollView {
            VStack(spacing: 16) {
                AsyncImage(url: data.avatarURL) { image in
                    image.resizable().scaledToFill()
                } placeholder: {
                    Circle().fill(Color.gray.opacity(0.3))
                }
                .frame(width: 96, height: 96)
                .clipShape(Circle())

                HStack(spacing: 4) {
                    Text(data.displayName).font(.title2).bold()
                    if data.isVerified {
                        Image(systemName: "checkmark.seal.fill")
                            .foregroundColor(.blue)
                    }
                }

                Text(data.username).foregroundColor(.secondary)
                Text(data.bio).multilineTextAlignment(.center).padding(.horizontal)

                HStack(spacing: 32) {
                    statColumn(value: data.formattedPosts, label: "Posts")
                    statColumn(value: data.formattedFollowers, label: "Followers")
                    statColumn(value: data.formattedFollowing, label: "Following")
                }
            }
            .padding()
        }
    }

    private func statColumn(value: String, label: String) -> some View {
        VStack {
            Text(value).font(.headline)
            Text(label).font(.caption).foregroundColor(.secondary)
        }
    }
}

Look at how thin the View is on both platforms. It has zero business logic. The ProfileDisplayData type contains pre-formatted strings — the View never calls formatCount itself. If you wanted to unit test the follower count formatting, you'd test ProfileDisplayData (or toDisplayData()) directly. No UI framework involved.

Interview Tip: When presenting MVVM in an interview, always mention testability. The ViewModel has no view-layer dependencies. You can instantiate it in a unit test, call loadProfile() with a mock repository, and assert that the state transitions from loading to loaded with the correct formatted data. Interviewers love hearing this because it shows you think about code quality, not just "making it work."

UIKit + MVVM#

An iOS-specific aside, and a useful contrast with the declarative Views above: before SwiftUI, MVVM on UIKit required manual binding. You'd use Combine, closures, or a library like RxSwift to observe ViewModel changes. Here's the Combine approach:

class ProfileViewController: UIViewController {
    private let viewModel: ProfileViewModel
    private var cancellables = Set<AnyCancellable>()

    init(viewModel: ProfileViewModel) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) { fatalError() }

    override func viewDidLoad() {
        super.viewDidLoad()
        setupBindings()
        Task { await viewModel.loadProfile() }
    }

    private func setupBindings() {
        viewModel.$state
            .receive(on: DispatchQueue.main)
            .sink { [weak self] state in
                switch state {
                case .loading:
                    self?.showLoadingIndicator()
                case .loaded(let data):
                    self?.updateUI(with: data)
                case .error(let message):
                    self?.showError(message)
                }
            }
            .store(in: &cancellables)
    }

    private func updateUI(with data: ProfileDisplayData) {
        nameLabel.text = data.displayName
        usernameLabel.text = data.username
        followersLabel.text = data.formattedFollowers
        // ... etc
    }
}

The [weak self] in the sink closure is easy to forget and leads to retain cycles. This is one reason SwiftUI's automatic observation system was such a welcome change.

Why StateFlow Over LiveData#

Now an Android-specific aside, with some history behind it. You'll see older Android codebases using LiveData, and it still works. But StateFlow is the modern choice for a few reasons:

  • StateFlow is a Kotlin coroutines primitive. It works in non-Android modules (shared KMM code, domain layer, repository layer) without pulling in Android dependencies.
  • LiveData is lifecycle-aware by default but only works on the main thread. StateFlow works on any dispatcher.
  • collectAsStateWithLifecycle() in Compose gives you lifecycle awareness with StateFlow, closing the gap.

If an interviewer asks about this, knowing the trade-off shows you stay current.

Configuration Changes: The Killer Feature#

Here's why Android's Jetpack ViewModel matters so much. Without it, rotating the phone destroys the Activity and recreates it. Any data you loaded — gone. Any in-flight API call — cancelled. The user sees a loading spinner again.

With a Jetpack ViewModel, the ViewModel instance survives the Activity recreation. The new Activity connects to the same ViewModel and immediately gets the current state. The user sees no interruption. No redundant network call. This alone justified MVVM's adoption on Android.

Interview Tip: When discussing Android architecture, mention SavedStateHandle alongside ViewModel. The ViewModel survives configuration changes but not process death (when Android kills your app in the background to free memory). SavedStateHandle lets you persist small amounts of critical state across process death — like a search query or a selected tab index. Knowing this distinction signals real production experience.

Data Binding: The Mechanism That Makes It Work#

The "VM" in MVVM only works if the View can observe ViewModel state changes automatically. This is data binding — the mechanism connecting the two layers.

Reactive Binding#

Modern approaches use reactive streams:

  • iOS (SwiftUI): @Published + @ObservedObject/@StateObject. The framework detects when a published property changes and re-renders affected views automatically.
  • iOS (UIKit): Combine publishers with sink, or RxSwift's Observable + bind(to:).
  • Android (Compose): StateFlow + collectAsStateWithLifecycle(). Compose recomposes when the collected state value changes.
  • Android (XML): The Data Binding Library with <layout> XML tags, or LiveData.observe() in the Activity/Fragment.

Reactive binding is declarative. You describe what the View should look like for a given state. The framework figures out when and how to update it.

Manual Binding#

Sometimes reactive binding is overkill. If you have a simple screen with a one-time data load and no interactive state, you can just call a method that sets all the UI properties:

func updateUI(with data: ProfileDisplayData) {
    nameLabel.text = data.displayName
    followersLabel.text = data.formattedFollowers
}

This is fine for simple screens, but it breaks down when you have multiple independent state changes. You end up writing update methods for every combination of state changes, or you re-render everything every time anything changes. Reactive binding handles partial updates more gracefully.

Trade-Offs#

ApproachProsCons
Reactive bindingAutomatic updates, less boilerplate, composableLearning curve, harder to debug, potential over-rendering
Manual bindingSimple, explicit, easy to debugTedious for complex state, easy to miss updates, more code

In an interview, I would default to reactive binding and mention that it's the standard approach on both platforms. But knowing that manual binding exists and when it might be appropriate shows maturity.

When MVVM Breaks Down#

MVVM is not a silver bullet. I have seen it misapplied as often as I have seen it used well. Here are the real pain points:

Where does navigation live? The View shouldn't decide where to go — that's logic. The ViewModel shouldn't know about UIKit's UINavigationController or Android's NavController — those are framework concerns. This is a genuine gap in MVVM.

Common solutions:

  • Coordinator pattern (iOS): A separate Coordinator object handles navigation, and the ViewModel signals navigation events through callbacks or published values.
  • Navigation component (Android): The Compose Navigation library or Jetpack Navigation handles it at a higher level, with the ViewModel exposing navigation events.
  • Router pattern: A dedicated Router class that the ViewModel calls, abstracting platform navigation APIs.

None of these are part of MVVM itself. You are bolting on extra patterns to fill the gap.

Shared State Between ViewModels#

Imagine a shopping app. The user adds an item to their cart from the product detail screen. The cart icon in the tab bar (owned by a different ViewModel) needs to update its badge count. How do those two ViewModels communicate?

Options:

  • A shared state object (like a CartManager singleton or injected dependency) that both ViewModels observe.
  • An event bus or notification system.
  • Passing state up to a parent and back down.

This is solvable, but MVVM doesn't prescribe an answer. In interviews, acknowledge the problem and pick one approach. I usually go with a shared repository that both ViewModels depend on, using dependency injection.

Complex Multi-Screen Flows#

Checkout flows, onboarding wizards, multi-step forms — these involve orchestrating state across several screens. A single ViewModel per screen doesn't cut it. You often need a "flow ViewModel" or coordinator that manages the overall flow state, with individual screen ViewModels handling their local concerns.

ViewModel Bloat#

The irony: MVVM was supposed to fix "Massive View Controller," but if you are not careful, you just end up with Massive ViewModels. If your ViewModel is handling networking, caching, formatting, validation, analytics, and state management, it has the same problem. The fix is to push logic further down — into repositories, use cases, or service classes — and keep the ViewModel focused on state management and presentation logic.

Interview Tip: Mentioning MVVM's limitations unprompted is one of the strongest signals you can send in an interview. It shows you have actually used the pattern in production, not just read about it. Say something like: "I'd use MVVM here, but for the checkout flow I'd add a Coordinator to manage cross-screen navigation, since MVVM doesn't handle that well on its own."

MVVM in Interviews: How to Present It#

When an interviewer asks about architecture, they are not looking for a Wikipedia definition. They want to know three things:

  1. Do you understand it well enough to apply it? Draw the data flow, not just three boxes with labels.
  2. Can you justify choosing it over alternatives? "I'm using MVVM because this screen has complex, reactive state — the feed items can be liked, bookmarked, and refreshed independently, and MVVM's reactive binding handles that cleanly."
  3. Do you know where it falls short? See the section above.

What to Say#

"For this feature, I'd use MVVM. The View observes a state object exposed by the ViewModel. When the user takes an action — say, liking a post — the View calls a method on the ViewModel, which updates the Model layer and publishes a new state. The View reacts to that state change and re-renders the affected row.

I'd keep the ViewModel free of platform imports. On iOS, it's an ObservableObject. On Android, it extends Jetpack ViewModel and exposes a StateFlow. The data formatting — timestamps, follower counts, relative dates — all lives in the ViewModel so it's unit testable without a device."

What Not to Say#

  • "I always use MVVM." — This sounds like you don't evaluate trade-offs.
  • "MVVM is the best pattern." — There is no best pattern. There are patterns that fit specific problems.
  • "The ViewModel talks to the View." — No. The ViewModel exposes state. The View observes it. The ViewModel has no reference to the View. This is a core distinction.

MVVM vs MVP vs MVC: A Concise Comparison#

You don't need to write a thesis on this, but you should be able to articulate the differences quickly.

MVC (Model-View-Controller)#

The Controller sits between the Model and View. It responds to user actions, updates the Model, and updates the View. The problem: on mobile, the Controller is the View (UIViewController, Activity). There's no real separation.

Data flow: User Action -> Controller -> Model -> Controller -> View (manual update)

Best for: Simple apps, prototypes, or when Apple/Google's default patterns are sufficient.

MVP (Model-View-Presenter)#

The Presenter replaces the Controller and communicates with the View through a protocol/interface. The View implements that interface, so the Presenter doesn't depend on UIKit or Android framework classes. This makes the Presenter unit testable.

Data flow: User Action -> View -> Presenter -> Model -> Presenter -> View (via interface callback)

Best for: UIKit apps where you want testable presentation logic, or when you want to avoid reactive programming.

MVVM (Model-View-ViewModel)#

The ViewModel exposes state that the View observes reactively. No protocol/interface between them — the View subscribes to state changes directly.

Data flow: User Action -> View -> ViewModel -> Model -> ViewModel (publishes state) -> View (observes and re-renders)

Best for: Apps with reactive UI requirements, SwiftUI or Compose projects, complex screens with multiple independent state changes.

The Key Difference#

MVP uses explicit callbacks (the Presenter tells the View what to do through an interface). MVVM uses observation (the View watches the ViewModel's state and reacts to changes). This seems like a small distinction, but it changes how you write code significantly. With MVP, the Presenter has a reference to the View interface. With MVVM, the ViewModel has no reference to the View at all.

AspectMVCMVPMVVM
View-Logic couplingHighMedium (via interface)Low (via observation)
TestabilityLowHighHigh
BoilerplateLowHigh (interfaces)Medium
Learning curveLowMediumMedium-High
Reactive framework neededNoNoTypically yes
Modern framework fitPoorDecent (UIKit)Excellent (SwiftUI, Compose)

Wrapping Up#

MVVM is the default choice for modern mobile development, and for good reason. It fits naturally with SwiftUI and Compose. It separates presentation logic from views. It makes your code testable. And on Android, the Jetpack ViewModel solves the configuration change problem that plagued developers for years.

But default choice doesn't mean only choice. In an interview, show that you understand MVVM deeply — the data flow, the binding mechanism, the separation of concerns — and that you also know when to reach for something else. A Coordinator for navigation. A shared repository for cross-ViewModel state. Use cases for complex business logic that doesn't belong in the ViewModel.

The candidates who stand out don't just name the pattern. They explain how data moves through the system, why each layer exists, and what happens when things go wrong. That's the level of understanding this article should give you.

Interview Tip: Practice explaining MVVM's data flow out loud, in under 60 seconds, for a specific screen (not in the abstract). "When the user pulls to refresh, the View calls viewModel.refresh(). The ViewModel sets state to loading, calls the repository, gets back a list of posts, maps them to display models with formatted timestamps, and publishes the new state. The View observes the state change and updates the list." That's a complete, confident answer. Rehearse it until it's natural.