---
title: SwiftUI Is Like React + CSS-in-JS
url: https://varstatt.com/jurij/p/swiftui-is-like-react-plus-css-in-js
author: Jurij Tokarski
date: 2026-03-04
description: 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.
section: Blog (https://varstatt.com/jurij/archive)
---

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 `@State` variable and the view re-renders. Same contract as `useState`, 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.

<Compare left="React" right="SwiftUI">
<div>
```jsx
function NoteCard({
  title,
  subtitle,
  dateLabel
}) {
  return (
    <div className="note-card">
      <h3>{title}</h3>
      <p>{subtitle}</p>
      <p>{dateLabel}</p>
    </div>
  );
}
```
</div>
<div>
```swift
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"))
    }
}
```
</div>
</Compare>

Key differences:

-   `struct` instead of `function`
-   `var body: some View` instead of `return`
-   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.

<Compare left="CSS Flexbox" right="SwiftUI">
<div>
```css
.container {
  display: flex;
  flex-direction: column;
}

.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}
```
</div>
<div>
```swift
// 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)
}
```
</div>
</Compare>

Layout containers:

-   `VStack` → `display: flex; flex-direction: column`
-   `HStack` → `display: flex; flex-direction: row`
-   `ZStack` → `position: relative` + absolute children
-   `Spacer()` → `flex-grow: 1`
-   `LazyVGrid` → 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.

<Compare left="CSS" right="SwiftUI">
<div>
```css
.card {
  padding: 16px;
  background-color: #fff;
  border-radius: 12px;
  box-shadow: 0 1px 2px rgba(0,0,0,0.06);
}
```
</div>
<div>
```swift
VStack { ... }
    .padding(16)
    .background(Color("NeutralCool0"))
    .cornerRadius(12)
    .shadow(
        color: Color.black.opacity(0.06),
        radius: 2, x: 0, y: 1
    )
```
</div>
</Compare>

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(...)` → `::before` or `::after` pseudo-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.

<Compare left="React" right="SwiftUI">
<div>
```jsx
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>
  );
}
```
</div>
<div>
```swift
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()
            }
        }
    }
}
```
</div>
</Compare>

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.

```swift
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.

<Compare left="React" right="SwiftUI">
<div>
```jsx
{isLoading ? (
  <Spinner />
) : error ? (
  <ErrorMessage error={error} />
) : (
  <DataList items={items} />
)}
```
</div>
<div>
```swift
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(...)
        }
    }
}
```
</div>
</Compare>

Key differences:

-   No ternary nesting — `if/else` reads linearly
-   `if let` unwraps 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.

<Compare left="React" right="SwiftUI">
<div>
```jsx
{items.map(item => (
  <ItemCard key={item.id} item={item} />
))}
```
</div>
<div>
```swift
ForEach(viewModel.items) { item in
    ItemCardView(
        title: item.name,
        subtitle: item.description,
        date: formattedDate(item.createdAt)
    )
}
```
</div>
</Compare>

Key differences:

-   `ForEach` replaces `.map()` — no `key` prop needed if item conforms to `Identifiable`
-   For grids, swap `VStack` for `LazyVGrid` — CSS Grid with lazy loading built in
-   `LazyVGrid` takes an array of `GridItem` values 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.

<Compare left="React" right="SwiftUI">
<div>
```jsx
<button onClick={() => handleClick()}>
  Click me
</button>

<input onChange={(e) => setValue(e.target.value)} />
```
</div>
<div>
```swift
Button("Click me") {
    handleClick()
}

// $ = two-way binding
TextField("Enter text", text: $textValue)
```
</div>
</Compare>

Event modifiers:

-   `Button { action }` → `<button onClick={action}>`
-   `.onTapGesture { }` → `onClick` on any element
-   `.onAppear { }` → `useEffect` on mount
-   `.onDisappear { }` → `useEffect` cleanup on unmount
-   `TextField(..., 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.

<Compare left="CSS" right="SwiftUI">
<div>
```css
:root { --primary-color: #00a86b; }

.button {
  background-color: var(--primary-color);
}
```
</div>
<div>
```swift
// Colors live in Assets.xcassets
.background(Color("PrimaryGreen600"))
.foregroundColor(Color("NeutralCool800"))
```
</div>
</Compare>

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:

```swift
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.

<Compare left="React" right="SwiftUI">
<div>
```jsx
async function loadData() {
  setLoading(true);
  try {
    const data = await fetch('/api/data');
    setItems(data);
  } catch (error) {
    setError(error.message);
  } finally {
    setLoading(false);
  }
}

useEffect(() => {
  loadData();
}, []);
```
</div>
<div>
```swift
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() }
}
```
</div>
</Compare>

Key differences:

-   `.onAppear` + `Task { }` replaces `useEffect` on mount
-   `do/catch` replaces `try/catch` — same idea, slightly different syntax
-   `@MainActor` on 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.

<Compare left="React Router" right="SwiftUI">
<div>
```jsx
<Router>
  <Routes>
    <Route path="/" element={<Home />} />
    <Route
      path="/item/:id"
      element={<ItemDetail />}
    />
  </Routes>
</Router>

<Link to={`/item/${item.id}`}>
  <ItemCard />
</Link>
```
</div>
<div>
```swift
// like <Router>
NavigationStack {
    ScrollView {
        ForEach(viewModel.items) { item in
            // like <Link>
            NavigationLink(
                destination: ItemDetailView(
                    id: item.id
                )
            ) {
                ItemCardView(...)
            }
        }
    }
}
```
</div>
</Compare>

Key differences:

-   No central route config — destination is declared at the link site
-   `NavigationStack` manages the back stack automatically
-   Wrap the root view once; all nested `NavigationLink`s 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.

<Compare left="CSS" right="SwiftUI">
<div>
```css
.container {
  padding: 20px;
}

@media (min-width: 768px) {
  .container {
    padding: 32px;
  }
}
```
</div>
<div>
```swift
private var isIPad: Bool {
    UIDevice.current.userInterfaceIdiom == .pad
}

var body: some View {
    VStack { ... }
        .padding(.horizontal, isIPad ? 32 : 20)
        .padding(.top, isIPad ? 69 : 16)
}
```
</div>
</Compare>

Key differences:

-   No breakpoints — device type replaces viewport width
-   Ternary inline on each modifier instead of a separate media block
-   `SizeClass` environment 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.
