Build an MVVM Login Screen in SwiftUI: Complete Example with Validation, Loading State, and Error Handling
You have probably built a login screen that looks perfectly fine at first. Two text fields, a login button, and maybe a “Forgot Password?” link. Everything works until you start adding real functionality.
What happens when someone enters an invalid email address? What if the password field is empty? What should the button display while the login request is running? And what happens if the server returns an error or the user's internet connection drops?
These are the details that turn a simple login form into a reliable part of an iOS application.
When working with SwiftUI, one approach I find particularly useful for organizing this functionality is Model-View-ViewModel (MVVM). It keeps the user interface focused on presentation while the ViewModel handles validation, login state, and the result of an authentication request.
In this tutorial, we'll build a complete login screen using SwiftUI and MVVM. We'll add email and password validation, a loading indicator, error messages, password visibility, and a simulated authentication service that you can later replace with your own API.
The example targets iOS 17 or later and uses modern SwiftUI APIs.
What We'll Build
Our login screen will include:
- An email field with basic format validation.
- A secure password field with a visibility toggle.
- A login button that becomes disabled when the form is invalid or a request is running.
- A loading indicator while authentication is in progress.
- Friendly error messages for invalid credentials and network failures.
- A ViewModel that manages the screen's state.
- An authentication service that can be replaced with a real backend implementation.
The goal is not just to make the screen look good. We want to make its behavior predictable, easy to test, and straightforward to maintain.
1. Understand the MVVM Structure
Before writing the code, let's look at how the pieces fit together.
MVVM separates an application screen into three main parts.
Model: Represents the data and business concepts used by the application. In our example, the authentication service and authentication-related errors belong to this layer.
View: Displays the login form and responds to user interactions. SwiftUI handles the text fields, buttons, loading indicator, and error messages.
ViewModel: Connects the view to the application's logic. It validates the input, manages loading and error states, and asks the authentication service to sign in.
Here is the flow:
- The user enters an email address and password.
- SwiftUI updates the values observed by the ViewModel.
- The ViewModel determines whether the form is valid.
- The user taps the login button.
- The ViewModel starts the authentication request and updates the loading state.
- The service returns a success or failure result.
- The ViewModel publishes the result, and SwiftUI updates the screen.
This separation becomes especially helpful when a login screen grows to include biometric authentication, multi-factor authentication, or password reset functionality.
2. Create the Project Structure
Create a new iOS project in Xcode using the App template and select SwiftUI as the interface.
For a small project, you can keep everything in one file while learning. For a more maintainable application, I recommend separating the files like this:
Each file has one clear responsibility. You can also introduce a separate authentication protocol when you want to mock the service during unit tests.
Let's implement the pieces one at a time.
3. Define Authentication Errors
Create a file named AuthError.swift.
Authentication can fail for different reasons. Invalid credentials should not produce the same message as a server outage or an unavailable network.
We can represent these situations with a Swift error type.
import Foundation
enum AuthError: LocalizedError {
case invalidCredentials
case networkUnavailable
case serverError
var errorDescription: String? {
switch self {
case .invalidCredentials:
return "The email or password is incorrect."
case .networkUnavailable:
return "Unable to connect. Check your internet connection."
case .serverError:
return "Something went wrong. Please try again."
}
}
}Using an enum is cleaner than scattering hard-coded error strings throughout the ViewModel.
It also makes it easier to decide how different failures should be displayed. For example, a production application might offer a retry button for a temporary server failure but direct the user to reset their password after repeated authentication failures.
Remember that the messages displayed to users should not expose internal server details or sensitive authentication information.
4. Create the Authentication Service
Next, create AuthService.swift.
For this tutorial, we'll simulate a network request using Swift concurrency. The service will accept one example account and reject other credentials.
This is useful for testing the user interface without needing to configure a backend server.
import Foundation
protocol AuthServiceProtocol {
func login(
email: String,
password: String
) async throws
}
struct AuthService: AuthServiceProtocol {
func login(
email: String,
password: String
) async throws {
// Simulate network latency.
try await Task.sleep(
nanoseconds: 1_500_000_000
)
if email.lowercased() == "demo@example.com",
password == "Password123!" {
return
}
throw AuthError.invalidCredentials
}
}For testing, use these credentials:
- Email:
demo@example.com - Password:
Password123!
The delay allows us to see the loading indicator in action.
Important: This is a mock authentication service, not real authentication. The credentials are hard-coded for demonstration purposes. Never ship a production login system that validates users using credentials embedded in the application. Real authentication must be performed by a trusted backend over HTTPS.
In a production application, this service would send a request to your authentication endpoint, decode the response, and translate relevant failures into application-level errors.
Keeping authentication in a separate service means the SwiftUI view doesn't need to know how the backend works.
5. Build the Login ViewModel
Now comes the main part of the MVVM implementation.
Create LoginViewModel.swift.
The ViewModel will manage the email, password, loading state, validation, and error message. It will also call the authentication service when the user attempts to log in.
import Foundation
import Combine
@MainActor
final class LoginViewModel: ObservableObject {
@Published var email = ""
@Published var password = ""
@Published private(set) var isLoading = false
@Published private(set) var errorMessage: String?
@Published private(set) var isLoggedIn = false
private let authService: any AuthServiceProtocol
init(
authService: any AuthServiceProtocol = AuthService()
) {
self.authService = authService
}
var isEmailValid: Bool {
let value = email.trimmingCharacters(
in: .whitespacesAndNewlines
)
let parts = value.split(
separator: "@",
omittingEmptySubsequences: false
)
guard parts.count == 2,
!parts[0].isEmpty else {
return false
}
let domainParts = parts[1].split(
separator: ".",
omittingEmptySubsequences: false
)
return domainParts.count >= 2 &&
domainParts.allSatisfy { !$0.isEmpty } &&
domainParts.last!.count >= 2 &&
!value.contains(" ")
}
var isPasswordValid: Bool {
!password.trimmingCharacters(
in: .whitespacesAndNewlines
).isEmpty
}
var canLogin: Bool {
isEmailValid &&
isPasswordValid &&
!isLoading
}
func login() async {
guard canLogin else { return }
errorMessage = nil
isLoading = true
isLoggedIn = false
defer {
isLoading = false
}
do {
try await authService.login(
email: email.trimmingCharacters(
in: .whitespacesAndNewlines
),
password: password
)
isLoggedIn = true
} catch {
if let authError = error as? AuthError {
errorMessage = authError.localizedDescription
} else {
errorMessage = "Unable to sign in. Please try again."
}
}
}
}Why use @MainActor?
The ViewModel publishes values that directly affect the interface. Those values should be updated on the main actor.
Using @MainActor makes that intention explicit and helps prevent accidental UI-state updates from an inappropriate execution context.
The authentication service can perform asynchronous work without requiring the view to manage networking details.
Why use @Published?
The @Published properties notify SwiftUI when relevant values change.
For example, when isLoading changes from false to true, the view can replace the login button's title with a progress indicator. When errorMessage receives a value, the error message becomes visible.
The private(set) modifier is also useful. The view can read isLoading and isLoggedIn, but it cannot directly change those properties. The ViewModel remains responsible for controlling the authentication flow.
Why use defer?
This line is easy to overlook:
defer {
isLoading = false
}It guarantees that the loading state is reset when the function exits, whether the authentication succeeds or throws an error.
Without it, a failed request can leave the button stuck in a loading state if one error path forgets to reset the flag.
Is this email validation enough?
It is sufficient for demonstrating a simple login form, but it is not a complete implementation of every valid email-address format.
For most applications, a practical client-side check is intended to catch obvious typing mistakes, not prove that an email address exists. The backend remains responsible for authentication and any required account verification.
Likewise, the password check above only rejects an empty or whitespace-only password. It does not impose a new password-length rule on existing users, which is important because login validation should match the rules of the actual authentication system.
6. Build the SwiftUI Login Screen
Now create LoginView.swift.
This is the part users interact with directly. We'll keep the layout simple, with a title, email field, password field, visibility toggle, error message, and login button.
The ViewModel will supply all the data and handle the authentication process.
import SwiftUI
struct LoginView: View {
@StateObject private var viewModel = LoginViewModel()
@State private var isPasswordVisible = false
var body: some View {
NavigationStack {
VStack(spacing: 22) {
Spacer()
Image(systemName: "lock.shield.fill")
.font(.system(size: 56))
.foregroundStyle(.blue)
VStack(spacing: 8) {
Text("Welcome Back")
.font(.largeTitle.bold())
Text("Sign in to continue")
.foregroundStyle(.secondary)
}
VStack(spacing: 16) {
TextField(
"Email address",
text: $viewModel.email
)
.keyboardType(.emailAddress)
.textContentType(.emailAddress)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.accessibilityLabel("Email address")
HStack {
Group {
if isPasswordVisible {
TextField(
"Password",
text: $viewModel.password
)
} else {
SecureField(
"Password",
text: $viewModel.password
)
}
}
.textContentType(.password)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button {
isPasswordVisible.toggle()
} label: {
Image(
systemName: isPasswordVisible
? "eye.slash"
: "eye"
)
.foregroundStyle(.secondary)
}
.accessibilityLabel(
isPasswordVisible
? "Hide password"
: "Show password"
)
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
if let error = viewModel.errorMessage {
Text(error)
.font(.subheadline)
.foregroundStyle(.red)
.frame(
maxWidth: .infinity,
alignment: .leading
)
.accessibilityLabel(
"Login error: \(error)"
)
}
Button {
Task {
await viewModel.login()
}
} label: {
Group {
if viewModel.isLoading {
ProgressView()
.tint(.white)
} else {
Text("Log In")
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.frame(height: 52)
.foregroundStyle(.white)
.background(
viewModel.canLogin
? Color.blue
: Color.gray
)
.clipShape(
RoundedRectangle(cornerRadius: 12)
)
}
.disabled(!viewModel.canLogin)
}
if viewModel.isLoggedIn {
Label(
"Login successful",
systemImage: "checkmark.circle.fill"
)
.foregroundStyle(.green)
.accessibilityAddTraits(.updatesFrequently)
}
Spacer()
Text("Secure access to your account")
.font(.footnote)
.foregroundStyle(.secondary)
.padding(.bottom, 12)
}
.padding(.horizontal, 24)
.padding(.vertical, 20)
.navigationBarHidden(true)
}
}
}
#Preview {
LoginView()
}What makes this view work?
A few implementation details are worth understanding.
@StateObject keeps the ViewModel alive. The view creates its ViewModel once for its identity, rather than creating a new instance every time SwiftUI recomputes the body.
$viewModel.email creates a binding. When the user types, the ViewModel's email property updates automatically. The same applies to the password field.
SecureField protects password entry on screen. The visibility toggle switches between SecureField and TextField for convenience. Be careful with password visibility in shared or public environments.
.disabled(!viewModel.canLogin) prevents invalid submissions. The login button becomes available only when both fields pass the current validation rules and no request is running.
ProgressView() communicates that work is happening. Instead of leaving users wondering whether their tap registered, the interface gives immediate feedback.
One small but important detail: the loading state is managed by the ViewModel, not by a separate Boolean inside the view. That keeps the authentication process and its state in one place.
7. Add the App Entry Point
If you're creating a new project, Xcode will usually generate an app entry file for you. Update it to display the login screen.
Create or update LoginMVVMApp.swift:
import SwiftUI
@main
struct LoginMVVMApp: App {
var body: some Scene {
WindowGroup {
LoginView()
}
}
}Run the project in an iOS simulator.
You should see the login screen with two input fields and a disabled login button. Enter the demo credentials from the authentication service to test the successful path.
8. Test the Different Login States
A login screen should be tested with more than one set of credentials. Try the following scenarios.
| Test case | Expected behavior |
|---|---|
| Empty email and password | Login button remains disabled |
| Invalid email format | Login button remains disabled |
| Valid email, empty password | Login button remains disabled |
| Incorrect password | Error message appears |
| Demo credentials | Login successful message appears |
| Authentication in progress | Progress indicator appears and button is disabled |
| Repeated button taps | A second request cannot start through the normal UI flow |
The loading state lasts approximately 1.5 seconds in this demo. That gives you time to see the progress indicator.
If you want to test network failures, extend the mock service to throw AuthError.networkUnavailable under a test condition. In a real implementation, the service should distinguish connectivity issues from server failures where it can do so reliably.
What about cancellation?
The example uses a Task created when the button is tapped. For a small login screen, this is straightforward. In a more complex application, you may want to retain the task handle so that requests can be cancelled when appropriate, such as when a user navigates away.
Also, the UI's disabled state prevents ordinary repeated taps, but a production ViewModel should be defensive about concurrent calls. For example, add an explicit guard !isLoading check before beginning authentication if the method might be called from multiple entry points.
9. Improve Error Handling for a Real API
Our mock service currently throws a known authentication error. A production service also needs to handle failures such as HTTP error responses, malformed JSON, and connection timeouts.
For example, a networking layer might use URLSession and Swift concurrency:
let (data, response) = try await URLSession.shared.data(
from: url
)
guard let httpResponse = response as? HTTPURLResponse else {
throw AuthError.serverError
}
guard (200..<300).contains(httpResponse.statusCode) else {
throw AuthError.serverError
}This snippet illustrates response checking; it is not a complete login API implementation. An actual authentication request usually uses an HTTP method such as POST and sends the required credentials in the request body over HTTPS.
There are a few things to keep in mind when adding the real endpoint:
- Do not assume that a successful network connection means authentication succeeded.
- Validate the HTTP status code and decode the response according to the backend's contract.
- Handle
URLErrorand decoding failures without exposing raw technical messages to the user. - Avoid logging passwords, access tokens, or sensitive response data.
- Store authentication tokens using an appropriate secure mechanism, such as Keychain, rather than ordinary preferences.
- Make sure the backend enforces authentication, authorization, rate limits, and any account-security policies.
It's also important to avoid treating every error as invalid credentials. A temporary network failure should not tell the user that their password is wrong.
10. Common MVVM Login Screen Mistakes
These are some issues worth watching for when building or maintaining this kind of screen.
Putting all the logic in the SwiftUI view
It's tempting to put validation, networking, loading flags, and error handling directly inside LoginView.
That can work for a prototype, but it becomes difficult to maintain when the screen grows. Keeping the logic in a ViewModel makes it easier to test without rendering the entire interface.
Forgetting to reset the loading state
If a request fails and isLoading remains true, the button can stay disabled indefinitely.
Using defer is a simple way to reset loading state on both success and failure.
Using overly strict email validation
A login form should catch obvious mistakes without rejecting legitimate addresses unnecessarily. A basic client-side check is useful, but it cannot prove that an address exists.
The backend should remain the authority for authentication.
Showing errors that never disappear
Imagine entering the wrong password, seeing an error, and then correcting the email address while the old error remains on screen. That can feel confusing.
Consider clearing the previous error when the user edits either field. You can do this with onChange in the view or by handling changes in the ViewModel. If you choose the latter, keep the behavior consistent and avoid clearing useful server feedback at unexpected times.
Making the button appear active when it cannot be used
A disabled button should look disabled. In this example, the button uses a gray background when canLogin is false.
For a production interface, also check contrast, dynamic type, VoiceOver labels, and how the screen behaves on smaller devices.
Treating client-side validation as security
Disabling a button or checking an email format does not secure an authentication system. A modified client can bypass these checks.
Always validate credentials on the server and use secure transport for requests.
Forgetting to test on a real device
The simulator is excellent for development, but password entry, keyboard behavior, accessibility, and network conditions can differ on physical devices.
Test the screen on at least one real iPhone before considering it finished.
11. How to Unit Test the ViewModel
One advantage of separating authentication into a protocol is that we can replace the real service with a mock.
For example, a simple test double could look like this:
struct MockAuthService: AuthServiceProtocol {
let shouldSucceed: Bool
func login(
email: String,
password: String
) async throws {
if !shouldSucceed {
throw AuthError.invalidCredentials
}
}
}You can inject it into the ViewModel:
let viewModel = LoginViewModel(
authService: MockAuthService(shouldSucceed: false)
)Then write XCTest cases to verify that invalid input cannot be submitted, successful authentication sets isLoggedIn to true, and a failed request produces an appropriate error message.
For asynchronous tests, mark the test method async and use await viewModel.login() when calling the login method directly.
To make loading-state tests deterministic, consider using a mock service that can pause until the test explicitly releases it. This is more reliable than depending on a fixed sleep duration.
You should also test the service's mapping of HTTP responses to authentication errors separately from the ViewModel's state transitions. That keeps failures easier to diagnose.
12. When Should You Use MVVM in SwiftUI?
MVVM is a useful option when a screen has more than simple presentation logic.
A basic login form can be implemented without a ViewModel, but introducing one becomes valuable when you need to support:
- Multiple validation rules.
- API requests and retry behavior.
- Loading and error states.
- Unit testing.
- Shared authentication logic.
- More complex flows, such as two-factor authentication.
SwiftUI already encourages a declarative approach to building interfaces, so the ViewModel should not become a second UI framework. Keep it focused on state and behavior, and let SwiftUI handle rendering.
For a small screen, a single ViewModel and a service protocol may be enough. You do not need to create a large number of abstractions before they solve a real problem.
Final Thoughts
Building a login screen is a good way to practice SwiftUI and MVVM because it combines several everyday iOS development challenges in one place: input handling, state management, asynchronous work, validation, and error reporting.
The biggest lesson is to keep responsibilities clear. Let the view display the form, let the ViewModel manage the screen's state, and let the authentication service handle the communication with your backend.
Start with the working example, test both successful and failed logins, and then replace the simulated service with a real API. As the application grows, you can introduce dedicated request and response models, better validation, cancellation, and more comprehensive unit tests.
A login screen may be small, but getting these fundamentals right makes the rest of the authentication flow much easier to build and maintain.











0 Comments