SwiftUI Is Like React + CSS-in-JS
I had to jump into an iOS codebase with no SwiftUI experience. The fastest way in was mapping everything I already knew to the Swift equivalent.
A client brought me into an iOS project mid-stream. The app was already built — screens, navigation, state management, the works — and I needed to understand it fast enough to contribute meaningfully.
The codebase was SwiftUI. I had zero SwiftUI experience.
My usual move in this situation is to find the mental model that bridges the gap. I've been doing web development long enough that React, CSS, and async JavaScript are second nature. So instead of starting from scratch, I used Claude Code to explore the codebase and kept asking one question: what's the web equivalent of this?
After a few hours of that, something clicked. SwiftUI is not exotic. If you know React, you already know 80% of the concepts — just with different syntax and a few iOS-specific primitives layered on top.
This post is the reference I built during that session. I stripped the client-specific code and kept the patterns that apply universally.
Core Concept: SwiftUI = React + CSS-in-JS + Declarative UI
SwiftUI is declarative and component-based, just like React. Instead of JSX you write Swift, but the mental model is the same: describe what the UI should look like, and the framework figures out how to render it.
The three things that felt foreign to me at first — and clicked once I found the right analogy:
- No CSS files. Styles live directly on the component as chained modifiers. Think CSS-in-JS but without the library — it's just how the language works.
- No DOM. SwiftUI renders to native UIKit controls under the hood. You never touch them directly, just like you never touch the DOM in React.
- State drives everything. Change a
@Statevariable and the view re-renders. Same contract asuseState, same mental model, different syntax.
1. Components — Structs that return a view, not JSX
In React a component is a function that returns JSX. In SwiftUI it's a struct that conforms to the View protocol and implements a body property. The props become let constants declared directly on the struct, and instead of a return statement you describe the layout inline.
function NoteCard({
title,
subtitle,
dateLabel
}) {
return (
<div className="note-card">
<h3>{title}</h3>
<p>{subtitle}</p>
<p>{dateLabel}</p>
</div>
);
}
struct NoteCardView: View {
let title: String?
let subtitle: String
let dateLabel: String
var body: some View {
VStack(
alignment: .leading,
spacing: 8
) {
if let title = title {
Text(title)
}
Text(subtitle)
Text(dateLabel)
}
.padding(16)
.background(Color("NeutralCool0"))
}
}
Key differences:
structinstead offunctionvar body: some Viewinstead ofreturn- Swift types (
String?,Int) instead of JavaScript types - Modifiers (
.padding(),.background()) instead of CSS classes
2. Layout — Named containers instead of flex properties
On the web layout is a property you set on an element — display: flex, flex-direction: row. In SwiftUI layout is structural: you pick a container (VStack, HStack, ZStack) and nest views inside it. There are no flex properties to remember because the direction is baked into the container name.
.container {
display: flex;
flex-direction: column;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
// like flex-direction: column
VStack(spacing: 0) {
// like flex-direction: row
HStack {
Text("My App")
Spacer() // like flex-grow: 1
Button { ... } label: { ... }
}
.padding(.horizontal, 20)
}
Layout containers:
VStack→display: flex; flex-direction: columnHStack→display: flex; flex-direction: rowZStack→position: relative+ absolute childrenSpacer()→flex-grow: 1LazyVGrid→ CSS Grid
3. Styling — Modifiers chain where CSS classes would go
There are no class names, no stylesheets, no CSS-in-JS library. Every visual property is a modifier method chained directly onto the view. Order matters — .padding() before .background() gives a different result than after it, just like stacking CSS properties in a specific order can change rendering.
.card {
padding: 16px;
background-color: #fff;
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.06);
}
VStack { ... }
.padding(16)
.background(Color("NeutralCool0"))
.cornerRadius(12)
.shadow(
color: Color.black.opacity(0.06),
radius: 2, x: 0, y: 1
)
Common modifiers:
.padding(16)→padding: 16px.padding(.horizontal, 20)→padding-left: 20px; padding-right: 20px.background(Color.red)→background-color: red.foregroundColor(.blue)→color: blue.font(.system(size: 17))→font-size: 17px.fontWeight(.medium)→font-weight: 500.cornerRadius(12)→border-radius: 12px.frame(width: 100, height: 50)→width: 100px; height: 50px.frame(maxWidth: .infinity)→width: 100%.shadow(...)→box-shadow: ....overlay(...)→::beforeor::afterpseudo-element
4. State Management — Property wrappers instead of hooks
@State is useState. The syntax is different but the contract is identical: declare a variable, mutate it, the view re-renders. What takes more time to learn is the broader family of property wrappers — SwiftUI has several, each with a specific purpose, where React hooks tend to blur together.
function Counter() {
const [count, setCount] = useState(0);
const [loading, setLoading] = useState(false);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
{loading && <Spinner />}
</div>
);
}
struct CounterView: View {
@State private var count = 0
@State private var loading = false
var body: some View {
VStack {
Text("\(count)")
Button("Increment") {
count += 1
}
if loading {
ProgressView()
}
}
}
}
State property wrappers:
@State→useState()— local component state@StateObject→useState()with object — owns an ObservableObject@ObservedObject→ props (object) — observes external ObservableObject@Published→ state in class component — triggers UI updates@Binding→ props callback — two-way data binding
For shared state, ObservableObject fills the same role as React Context or Redux. You mark properties with @Published and the views that observe it re-render automatically — no dispatch, no selectors.
class AppViewModel: ObservableObject {
@Published var items: [Item] = []
@Published var isLoading: Bool = false
@Published var error: String? = nil
func loadItems() async {
isLoading = true
let result = try await repository.fetchItems()
self.items = result
isLoading = false
}
}
5. Conditional Rendering — Plain if/else, no ternary tricks
React leans on ternaries and && because JSX is an expression — it has to return something. SwiftUI uses plain if/else blocks because the body is just code, not an expression. The result is actually easier to read once you have three or four states to handle, since you're not nesting ternaries.
{isLoading ? (
<Spinner />
) : error ? (
<ErrorMessage error={error} />
) : (
<DataList items={items} />
)}
if viewModel.isLoading {
ProgressView("Loading...")
} else if let error = viewModel.error {
ErrorView(message: error)
} else if viewModel.items.isEmpty {
Text("Nothing here yet")
} else {
ScrollView {
ForEach(viewModel.items) { item in
ItemCardView(...)
}
}
}
Key differences:
- No ternary nesting —
if/elsereads linearly if letunwraps optionals inline — common Swift pattern- Empty state (
items.isEmpty) fits naturally as another branch
6. Lists — ForEach maps items to views
ForEach is Array.map(). The one gotcha: items need to conform to the Identifiable protocol, which is SwiftUI's version of React's key prop — it needs a stable identity to track which items changed between renders. In practice this usually means your model has an id property, which it probably already does.
{items.map(item => (
<ItemCard key={item.id} item={item} />
))}
ForEach(viewModel.items) { item in
ItemCardView(
title: item.name,
subtitle: item.description,
date: formattedDate(item.createdAt)
)
}
Key differences:
ForEachreplaces.map()— nokeyprop needed if item conforms toIdentifiable- For grids, swap
VStackforLazyVGrid— CSS Grid with lazy loading built in LazyVGridtakes an array ofGridItemvalues to define columns
7. Event Handlers — Actions as trailing closures
Web events are attributes you attach to elements. In SwiftUI interactive views like Button take an action closure as their first argument. For non-interactive views you attach .onTapGesture, .onAppear, or .onDisappear as modifiers — the same way you'd add onClick to a div in React.
<button onClick={() => handleClick()}>
Click me
</button>
<input onChange={(e) => setValue(e.target.value)} />
Button("Click me") {
handleClick()
}
// $ = two-way binding
TextField("Enter text", text: $textValue)
Event modifiers:
Button { action }→<button onClick={action}>.onTapGesture { }→onClickon any element.onAppear { }→useEffecton mount.onDisappear { }→useEffectcleanup on unmountTextField(..., text: $value)→<input onChange={...}>
8. Design Tokens — Color assets instead of CSS variables
CSS variables live in a stylesheet. SwiftUI color tokens live in Assets.xcassets — a file managed through Xcode's visual editor, not in code. You reference them by name string, which feels fragile at first, but in practice it works the same way. For component variants, Swift enums replace CSS class modifiers cleanly.
:root { --primary-color: #00a86b; }
.button {
background-color: var(--primary-color);
}
// Colors live in Assets.xcassets
.background(Color("PrimaryGreen600"))
.foregroundColor(Color("NeutralCool800"))
Key differences:
- Colors defined in
Assets.xcassets, referenced by name string - No cascade — colors don't inherit down the tree unless you set them explicitly
- Enums replace CSS class variants for component styles:
enum ButtonVariant {
case primary
case secondary
var backgroundColor: Color {
switch self {
case .primary: return Color("PrimaryGreen600")
case .secondary: return Color.clear
}
}
}
9. Async Operations — Task instead of useEffect
The async/await syntax is nearly identical to JavaScript — this was the one area where SwiftUI felt immediately familiar. The difference is how you trigger async work from a view: instead of useEffect, you use .onAppear combined with Task { }. Task creates a new async context from synchronous code, the same way you'd call an async IIFE in JavaScript.
async function loadData() {
setLoading(true);
try {
const data = await fetch('/api/data');
setItems(data);
} catch (error) {
setError(error.message);
} finally {
setLoading(false);
}
}
useEffect(() => {
loadData();
}, []);
func loadItems() async {
isLoading = true
do {
let items = try await repository.fetchItems()
self.items = items
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
.onAppear {
Task { await viewModel.loadItems() }
}
Key differences:
.onAppear+Task { }replacesuseEffecton mountdo/catchreplacestry/catch— same idea, slightly different syntax@MainActoron the ViewModel ensures UI updates happen on the main thread, same reason React's state setters are synchronous
10. Navigation — NavigationStack wraps the whole screen tree
React Router lives outside your component tree and you declare routes centrally. SwiftUI's NavigationStack wraps directly around the content — you place it in the view hierarchy and NavigationLink handles the push transition. It's more like Next.js file-based routing in feel: navigation is co-located with the UI that triggers it.
<Router>
<Routes>
<Route path="/" element={<Home />} />
<Route
path="/item/:id"
element={<ItemDetail />}
/>
</Routes>
</Router>
<Link to={`/item/${item.id}`}>
<ItemCard />
</Link>
// like <Router>
NavigationStack {
ScrollView {
ForEach(viewModel.items) { item in
// like <Link>
NavigationLink(
destination: ItemDetailView(
id: item.id
)
) {
ItemCardView(...)
}
}
}
}
Key differences:
- No central route config — destination is declared at the link site
NavigationStackmanages the back stack automatically- Wrap the root view once; all nested
NavigationLinks push onto the same stack
11. Responsive Design — Device checks instead of breakpoints
CSS media queries respond to viewport width. SwiftUI doesn't have a viewport — it has devices. The idiomatic approach is a simple device type check (UIDevice.current.userInterfaceIdiom) and a ternary at the modifier level. It's more explicit than media queries but also simpler: iPad or not iPad covers most cases.
.container {
padding: 20px;
}
@media (min-width: 768px) {
.container {
padding: 32px;
}
}
private var isIPad: Bool {
UIDevice.current.userInterfaceIdiom == .pad
}
var body: some View {
VStack { ... }
.padding(.horizontal, isIPad ? 32 : 20)
.padding(.top, isIPad ? 69 : 16)
}
Key differences:
- No breakpoints — device type replaces viewport width
- Ternary inline on each modifier instead of a separate media block
SizeClassenvironment variable available for more nuanced layouts
Summary
The syntax is new. The concepts are not. Every pattern here has a direct equivalent you already know — and once that clicks, reading an unfamiliar SwiftUI codebase stops feeling like learning a new paradigm and starts feeling like reading familiar code in an accent you haven't heard before.
The things that genuinely differ from web: no CSS files, no DOM, modifiers instead of class names, and Identifiable instead of key. Everything else maps cleanly.
If you're jumping into an iOS codebase, start with the component structure and state — get those two right and the rest follows.
Subscribe to the newsletter:
About Jurij Tokarski
Hey 👋 I'm Jurij. I run Varstatt and create software. Usually, I'm deep in the work shipping for clients or building for myself. Sometimes, I share bits I don't want to forget: mostly about software, products and self-employment.
