UI coding and take-homes
In this round you build something real. It is the round closest to your daily job. The interviewer wants to see if you can ship a clean, working feature, not just make it work.
There are two forms:
| Live UI coding | Take-home | |
|---|---|---|
| Time | 60–90 minutes | 3–8 hours, over a few days |
| Tool | Xcode with screen sharing, sometimes a plain editor | Your own Xcode |
| Scope | One screen, maybe two | A small app |
| Focus | Speed, structure, talking | Quality, tests, README |
| After | Questions during the session | A review call about your code |
A typical task: "Load a list of items from this URL. Show them in a list. Tap an item to see details."
Step 1: Clarify the task
Ask before you build. The same questions work live and for a take-home (send them by email).
- "SwiftUI or UIKit, or my choice?"
- "Which iOS version should I support?"
- "Can I use third-party libraries?" (Usually the answer is no, or "keep it small".)
- "Do you want tests? Offline support? Pagination?"
- "How much time should I spend?"
If you cannot ask, write your assumptions in the README.
Step 2: Plan the structure
Take 2 minutes to plan, even in a live round. Say it out loud.
"I will have a
Usermodel, a service protocol for networking, a view model that holds the screen state, and a SwiftUI view. The service is a protocol, so I can test the view model with a fake."
A small, clear folder structure:
UsersApp/
App/
UsersApp.swift
Models/
User.swift
Services/
UserService.swift
Features/
UserList/
UserListView.swift
UserListViewModel.swift
UserDetail/
UserDetailView.swift
UsersAppTests/
UserListViewModelTests.swift
README.mdDo not over-build. Three layers (view, view model, service) are enough for most tasks.
Step 3: Model and networking
Start with the data. Make the model Decodable and the service a protocol.
struct User: Decodable, Identifiable, Equatable, Sendable {
let id: Int
let name: String
let email: String
}
protocol UserService: Sendable {
func fetchUsers() async throws -> [User]
}
struct LiveUserService: UserService {
let url: URL
func fetchUsers() async throws -> [User] {
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode([User].self, from: data)
}
}Why reviewers like this:
- The status code is checked. Many candidates skip it.
- The protocol makes testing easy.
async/awaitkeeps the code short and readable.
Step 4: View model with clear states
Every screen that loads data has at least four states: loading, loaded, empty and error. Use an enum. It makes impossible states impossible (for example "loading" and "error" at the same time).
@MainActor
@Observable
final class UserListViewModel {
enum LoadState: Equatable {
case loading
case loaded([User])
case empty
case failed(String)
}
private(set) var state: LoadState = .loading
private let service: UserService
init(service: UserService) {
self.service = service
}
func load() async {
state = .loading
do {
let users = try await service.fetchUsers()
state = users.isEmpty ? .empty : .loaded(users)
} catch {
state = .failed("Could not load users. Please try again.")
}
}
}@MainActor means all state changes happen on the main thread. So the UI is always updated safely.
Step 5: The view
The view only reads the state and sends actions. No networking or parsing in the view.
import SwiftUI
struct UserListView: View {
@State private var viewModel: UserListViewModel
init(viewModel: UserListViewModel) {
_viewModel = State(initialValue: viewModel)
}
var body: some View {
content
.navigationTitle("Users")
.task { await viewModel.load() }
}
@ViewBuilder
private var content: some View {
switch viewModel.state {
case .loading:
ProgressView("Loading…")
case .loaded(let users):
List(users) { user in
NavigationLink(value: user.id) {
VStack(alignment: .leading) {
Text(user.name).font(.headline)
Text(user.email)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
.refreshable { await viewModel.load() }
case .empty:
ContentUnavailableView("No users yet", systemImage: "person.2")
case .failed(let message):
ContentUnavailableView {
Label("Something went wrong", systemImage: "wifi.exclamationmark")
} description: {
Text(message)
} actions: {
Button("Try again") {
Task { await viewModel.load() }
}
}
}
}
}Small details that score points:
- Pull to refresh with
.refreshable. - A "Try again" button on error.
- System fonts like
.headline, so Dynamic Type works. .taskinstead of.onAppear, so the work is cancelled when the view goes away.
Step 6: Tests
Test the view model, not the view. Use a fake service.
import Testing
@testable import UsersApp
struct FakeUserService: UserService {
let result: Result<[User], any Error>
func fetchUsers() async throws -> [User] {
try result.get()
}
}
@MainActor
struct UserListViewModelTests {
@Test func showsUsersAfterLoading() async {
let users = [User(id: 1, name: "Ana", email: "ana@example.com")]
let viewModel = UserListViewModel(service: FakeUserService(result: .success(users)))
await viewModel.load()
#expect(viewModel.state == .loaded(users))
}
@Test func showsEmptyStateForEmptyList() async {
let viewModel = UserListViewModel(service: FakeUserService(result: .success([])))
await viewModel.load()
#expect(viewModel.state == .empty)
}
@Test func showsErrorWhenRequestFails() async {
let viewModel = UserListViewModel(
service: FakeUserService(result: .failure(URLError(.notConnectedToInternet)))
)
await viewModel.load()
guard case .failed = viewModel.state else {
Issue.record("Expected the failed state, got \(viewModel.state)")
return
}
}
}Three tests that cover the main states are better than ten tests of trivial getters. If the team uses XCTest, the same tests work with XCTestCase and XCTAssertEqual.
In a live round with little time, say: "I would add tests for the loading, empty and error states." Then write at least one if you can.
Step 7: The README (take-homes)
The README is often the first thing a reviewer reads. A good one saves them time and explains your choices.
# Users App
## How to run
Open UsersApp.xcodeproj in Xcode 26 or later. Run the UsersApp scheme. No setup needed.
## What it does
- Loads users from the API and shows them in a list.
- Tap a user to see details.
- Pull to refresh. Loading, empty and error states.
## Architecture
MVVM. Views are SwiftUI. View models are @MainActor @Observable classes.
Networking is behind a UserService protocol, so view models are easy to test.
## Decisions and trade-offs
- No third-party libraries, to keep the project small.
- No disk cache. With more time I would cache the last response for offline use.
## Tests
UserListViewModelTests covers the loaded, empty and error states.
## With more time I would
- Add pagination.
- Add snapshot tests for the main screens.
- Improve VoiceOver labels on the detail screen.
## Time spent
About 5 hours.The "With more time" section is important. It shows you know what is missing, and that you chose to stop on purpose.
What reviewers check
| Area | What they look for |
|---|---|
| It works | Builds with no errors or warnings. No crashes. Follows the task. |
| Structure | Clear layers. Views do not do networking. Small files. |
| States | Loading, empty, error, and success are all handled. |
| Swift style | Clear names, no force unwraps, private where it should be. |
| Concurrency | UI updates on the main actor. No data races. Tasks cancelled when needed. |
| Tests | The important logic is tested. Tests are readable. |
| UX details | Pull to refresh, retry, Dynamic Type, dark mode, VoiceOver labels. |
| Git history | Small commits with clear messages, if they can see it. |
| README | How to run, decisions, trade-offs, what is missing. |
Live round tips
- Get something on screen fast. A working list with fake data in 15 minutes is a good start. Then add networking.
- Talk while you build. "Now I add the error state. I use an enum so the states cannot mix."
- Keep a short TODO list in a comment. It shows you have a plan.
- Do not fight Xcode. If the preview breaks, run in the simulator and move on.
- Leave 10 minutes for questions about your code and what you would improve.
// TODO (live round plan):
// 1. Model + fake data in the list <- done
// 2. Real networking
// 3. Error and empty states
// 4. Detail screen
// 5. One view model testTake-home tips
- Respect the time limit. If they say 4 hours, do not spend 20. Write the real time in the README.
- Quality over features. A small app with tests and states beats a big app with bugs.
- Clean up before sending. Remove commented-out code, print statements and unused files.
- Run it fresh. Clone your own repo into a new folder and build it. Make sure it works.
- Prepare for the review call. Be ready to explain every choice and to change the code live: "How would you add search?"
Common follow-up questions
- "How would you add pagination?" — Keep the current page in the view model, load the next page when the last cell appears, and stop when the server returns no more items.
- "How would you cache images?" — In-memory cache (for example
NSCache) plus a disk cache, keyed by URL, behind a protocol. - "How would you support offline?" — Save the last response to disk or SwiftData, show it first, then refresh from the network.
- "Why MVVM here?" — It moves logic out of the view, so it is easy to test. For a screen this small, it is enough without extra layers.
- "What would you change in UIKit?" — A
UITableViewwith a diffable data source, and the same view model.
Practise on this site
The UI build questions on this site have a list of requirements, a starter file and a full solution. Build each one in Xcode with a timer. Then compare your code with the solution and the checklist above.