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 @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.

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

  • 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.

CSS Flexbox
.container {
  display: flex;
  flex-direction: column;
}

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

  • VStackdisplay: flex; flex-direction: column
  • HStackdisplay: flex; flex-direction: row
  • ZStackposition: 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.

CSS
.card {
  padding: 16px;
  background-color: #fff;
  border-radius: 12px;
  box-shadow: 0 1px 2px rgba(0,0,0,0.06);
}
SwiftUI
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(...)::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.

React
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>
  );
}
SwiftUI
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:

  • @StateuseState() — local component state
  • @StateObjectuseState() 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.

React
{isLoading ? (
  <Spinner />
) : error ? (
  <ErrorMessage error={error} />
) : (
  <DataList items={items} />
)}
SwiftUI
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/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.

React
{items.map(item => (
  <ItemCard key={item.id} item={item} />
))}
SwiftUI
ForEach(viewModel.items) { item in
    ItemCardView(
        title: item.name,
        subtitle: item.description,
        date: formattedDate(item.createdAt)
    )
}

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.

React
<button onClick={() => handleClick()}>
  Click me
</button>

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

// $ = two-way binding
TextField("Enter text", text: $textValue)

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.

CSS
:root { --primary-color: #00a86b; }

.button {
  background-color: var(--primary-color);
}
SwiftUI
// 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.

React
async function loadData() {
  setLoading(true);
  try {
    const data = await fetch('/api/data');
    setItems(data);
  } catch (error) {
    setError(error.message);
  } finally {
    setLoading(false);
  }
}

useEffect(() => {
  loadData();
}, []);
SwiftUI
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 { } 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.

React Router
<Router>
  <Routes>
    <Route path="/" element={<Home />} />
    <Route
      path="/item/:id"
      element={<ItemDetail />}
    />
  </Routes>
</Router>

<Link to={`/item/${item.id}`}>
  <ItemCard />
</Link>
SwiftUI
// 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
  • NavigationStack manages 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.

CSS
.container {
  padding: 20px;
}

@media (min-width: 768px) {
  .container {
    padding: 32px;
  }
}
SwiftUI
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
  • 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.

Got thoughts on this post? Reply viaEmail/Twitter/X/LinkedIn

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.

RSSjurij@varstatt.comx.comlinkedin.com