· 11 min read

Looking at SwiftUI from a Frontend Perspective

This article was auto-translated from Chinese. Some nuances may be lost in translation.

Introduction

I have limited experience with iOS, mobile, and SwiftUI development, so please feel free to correct me if I’ve misunderstood anything.

From a UI perspective, frontend and mobile development face similar challenges. Although the languages and development approaches may differ, we both need to build intuitive user interfaces. Naturally, both worlds encounter analogous problems—component-driven development, state management, data flow, handling side effects (API calls or I/O), and so on. To me, this makes it a great space for cross-pollination and learning from each other.

Looking back, libraries like ReSwift (inspired by Redux’s core philosophy) clearly borrowed from the continuously evolving paradigms of frontend development. It’s easy to see that both domains share common struggles.

Even though it might be a bit late in the game to bring this up now, I still wanted to document my thoughts after getting started with SwiftUI.

Similarities Between SwiftUI and React

We can generalize frontend frameworks into several core elements:

  • Component-based architecture
  • Reactivity mechanisms
  • State management
  • Event handling
  • Lifecycles

In the sections below, we’ll center our discussion around these topics. To keep things focused, I’ll mostly use React as an example, though the concepts generally apply to other frontend frameworks as well.

Moving from Class to Struct; Moving from Class to Function

Writing SwiftUI frequently reminds me of React’s history. Initially, React components were created using JavaScript classes, where every component was an ES6 class.

class MyComponent extends React.Component {
  constructor() {
    this.state = {
      name: 'kalan'
    }
  }
  
  componentDidMount() {
    console.log('component is mounted')
  }
  
  render() {
    return <div>my name is {this.state.name}</div>
  }
}

While defining components through classes had a massive impact on the frontend component ecosystem, verbose method definitions and confusion surrounding this led React to introduce Hooks in version 16.8, encouraging a shift toward function components and hooks.

By ditching inheritance and various object-oriented design patterns, the mental overhead of building components decreased significantly. We can observe a similar evolution in SwiftUI: the massive classes and bloated responsibilities of traditional ViewControllers—which juggled View/Model interactions and lifecycles—have been replaced by lightweight structs. This allows developers to focus purely on UI interactions while reducing cognitive load.

Component State Management

React 16 adopted hooks for logic reuse and state management, such as useState.

const MyComponent = () => {
  const [name, setName] = useState({ name: 'kalan' })
  useEffect(() => { console.log('component is mounted') }, [])
  
  return <div>my name is {name}</div>
}

In SwiftUI, we can use the @State property wrapper to achieve a similar effect for a View. Both possess reactive mechanisms: when state variables change, React and Vue detect these mutations and reflect them in the UI. While I don’t know the exact internals of SwiftUI, it likely uses a similar diffing mechanism under the hood to achieve reactivity and minimal DOM/view updates.

However, state management in SwiftUI still differs from React hooks. In React, we can extract custom hooks into standalone functions and reuse them across different components:

function useToggle(initialValue) {
  const [toggle, set] = useState(initialValue)
  const setToggle = useCallback(() => { set((state) => !state) }, [toggle])
  useEffect(() => { console.log('toggle is set') }, [toggle])
  return [toggle, setToggle]
}

const MyComponent = () => {
  const [toggle, setToggle] = useToggle(false)
  return <button onClick={() => setToggle()}>Click me</button>
}

const MyToggle = () => {
  const [toggle, setToggle] = useToggle(true)
  return <button onClick={() => setToggle()}>Toggle, but fancy one</button>
}

In React, we can extract the toggle logic and share it across different components. Because useToggle is a pure function, internal states remain completely independent of one another.

In SwiftUI, however, @State can only be applied to a struct’s private properties and cannot be decoupled directly. If you want to extract reusable logic, you typically need wrappers like @Observable (or @ObservableObject) and @StateObject, creating a separate class to manage it:

class ToggleUtil: ObservableObject {
  @Published var toggle = false
  
  func setToggle() {
    self.toggle = !self.toggle
  }
}

struct ContentView: View {
  @StateObject var toggleUtil = ToggleUtil()
  var body: some View {
    Button("Text") {
      toggleUtil.setToggle()
    }

    if toggleUtil.toggle {
      Text("Show me!")
    }
  }
}

In this case, extracting simple toggle logic into an entire class feels like overkill. When you consider React’s hook mechanism, lightweight logic sharing never feels overly verbose, and complex logic can be composed into even smaller hooks. In that sense, hooks are indeed a remarkable mechanism. I wonder if SwiftUI has something directly equivalent.1

Before hooks arrived in React, there were primarily three ways to share logic:

  • HOC (Higher-Order Components)2: Wrapping shared logic in a function that returns a new component, avoiding direct mutation of internal implementations (e.g., connect in early react-redux).
  • Render props3: Passing the actual rendering component as a prop, exposing necessary state/arguments for the caller to render.
  • Children functions: Passing only the necessary parameters into children, letting the caller decide how to render the UI.

Even though hooks solve logic reuse much more elegantly, the evolution of these frontend patterns remains valuable reference material.

Redux and TCA

Influenced by Redux, some Swift developers adopted similar paradigms, which led to implementations like ReSwift. Its motivation was clear: traditional ViewControllers had blurry responsibilities, easily growing bloated and hard to maintain. By utilizing Reducers, Actions, and Store subscriptions, they enforced unidirectional data flow—all operations dispatch an action to the store, and state mutations are handled solely within the reducer.

Recently, the trend seems to have evolved from Redux toward TCA (The Composable Architecture). While sharing Redux’s core philosophy, it integrates much more seamlessly with SwiftUI. One notable difference is that side effects—which were traditionally delegated to middleware in Redux—can be returned directly as an Effect from a TCA reducer, representing I/O operations or API calls triggered by an action.

Since it borrows concepts from Redux, I wonder if SwiftUI runs into similar issues seen in frontend development: immutability requirements to guarantee changes are detected, subscription optimizations to ensure only relevant components re-render on store updates, and the notorious boilerplate burden of reducers and actions.

Although Redux still holds ground and is actively used in many companies, there has been a noticeable shift away from it in the frontend ecosystem. Redux’s strict adherence to pure functions combined with repetitive reducer and action code offers little noticeable benefit until an application reaches substantial complexity—even the creator of Redux largely stepped away from it4. Meanwhile, react-redux continues to evolve, introducing Redux Toolkit to streamline many of these friction points.

In its place, more lightweight state management paradigms have emerged across frontend communities:

Global State Management

For global state management, SwiftUI provides a built-in mechanism called @EnvironmentObject. It works very similarly to React’s Context, allowing components across the hierarchy to access variables and automatically re-rendering them when the context changes.

class User: ObservableObject {
  @Published var name = "kalan"
  @Published var age = 20
}

struct UserInfo: View {
  @EnvironmentObject var user: User
  var body: some View {
    Text(user.name)
    Text(String(user.age))
  }
}

struct ContentView: View {
	var body: some View {
		UserInfo()
  }  
}

ContentView().envrionmentObject(User())

As demonstrated above, we don’t need to manually pass user into UserInfo; it accesses the current context via @EnvironmentObject. In React, this translates to:

const userContext = createContext({})

const UserInfo = () => {
  const { name, age } = useContext(userContext)
  return <>
    <p>{name}</p>
    <p>{age}</p>
  </>
}

const App = () => {
  <userContext.Provider value={{ user: 'kalan', age: 20 }}>
    <UserInfo />
  </userContext.Provider>
}

React Context enables cross-hierarchy state access and updates components when context values change. While it effectively avoids prop drilling, context can make testing more cumbersome due to the implicit coupling it introduces.

Reactivity Mechanisms

In React, changes to state or props trigger component re-renders, with framework-level diffing computing the minimal updates needed on the DOM. SwiftUI features a very similar pattern:

struct MyView: View {
  var name: String
  @State private var isHidden = false
  
  var body: some View {
    Toggle(isOn: $isHidden) {
      Text("Hidden")
    }

    Text("Hello world")
    
    if !isHidden {
      Text("Show me \(name)")
    }
  }
}

A typical SwiftUI component is a struct that defines its UI via the body property. Just like in React, these views are abstract representations of UI; the framework diffs the underlying data structures to calculate minimal updates before rendering them to the screen.

I’m quite curious about how SwiftUI calculates diffs under the hood, and I hope more in-depth articles on that topic appear in the future.

The @State wrapper defines internal component state; when updated, the view invalidates and reflects the new state on screen.

In SwiftUI, properties (like name in MyView) can be passed in from the parent, functioning just like props in React.

// Using MyView in another View
struct ContentView: View {
  var body: some View {
    MyView(name: "kalan")
  }
}

Rewriting this component in React looks like this:

const MyView = ({ name }) => {
  const [isHidden, setIsHidden] = useState(false)
  return <div>
    <button onClick={() => setIsHidden(state => !state)}>hidden</button>
    <p>Hello world</p>
    {isHidden ? null : `show me ${name}`}
  </div>
}

Writing SwiftUI reveals how fundamentally different this declarative approach is from traditional UIKit and UIViewController workflows.

Lists

Both SwiftUI and React can render lists, and their syntax looks remarkably similar. In SwiftUI, you can write:

struct TextListView: View {
  var body: some View {
    List {
      ForEach([
        "iPhone",
        "Android",
        "Mac"
      ], id: \.self) { value in
        Text(value)
      }
    }
  }
}

Translated to React, it looks roughly like this:

const TextList = () => {
  const list = ['iPhone', 'Android', 'Mac']
  
  return list.map(item => <p key={item}>{item}</p>)
}

To ensure performance and minimize unnecessary DOM operations during list reconciliation, React requires developers to provide a key. SwiftUI has an equivalent mechanism: developers must conform data items to the Identifiable protocol or explicitly pass an id.

Binding

Beyond binding variables to the view, we can also bind user interactions back to variables. In SwiftUI, we can write:

struct MyInput: View {
  @State private var text = ""
  var body: some View {
    TextField("Please type something", text: $text)
  }
}

In this example, without manually attaching an input event listener, using $text directly mutates the text variable. When declared with @State, the property wrapper exposes a projected value prefixed with $, returning a Binding type.

React does not offer two-way data binding; developers must explicitly listen to input events to maintain unidirectional data flow. On the other hand, frameworks like Vue and Svelte do provide two-way binding, eliminating the boilerplate of manually attaching event listeners.

The Arrival of Combine

Although I’m not yet deeply familiar with Combine, judging from Apple’s documentation and WWDC sessions, it feels like Swift’s flavor of RxJS. The APIs and operators it offers drastically simplify handling complex asynchronous data flows. It took me back to the days of exploring intricate RxJS and redux-observable pipelines—good times.

Fundamental Differences

Despite the shared paradigms, web and mobile development diverge significantly under the hood. To me, the most striking distinction is static compilation versus dynamic runtime execution—the latter being one of the web’s greatest strengths.

As long as a browser exists, JavaScript, HTML, and CSS can execute on virtually any device. Web pages don’t require downloading 10MB to hundreds of megabytes of binaries upfront; scripts execute dynamically, fetching and streaming content on demand as the user navigates.

Because it doesn’t require prior compilation on the client, anyone can inspect web content and scripts. With HTML streaming, browsers can parse and render content concurrently. Most importantly, the web is decentralized: as long as you have a server, an IP address, and a domain, anyone can access your site. In contrast, native apps must pass platform review processes before reaching users.

Even though their ecosystems and day-to-day practices differ, keeping an eye on each other’s evolution is immensely rewarding. Even if you don’t use both on a daily basis, examining engineering challenges from another platform’s perspective uncovers fresh insights and sharpens your overall technical intuition.

Footnotes

  1. Later I came across SwiftUI-Hooks, though I haven’t evaluated how well it performs in real-world scenarios. ↩

  2. https://zh-hant.reactjs.org/docs/higher-order-components.html ↩

  3. https://zh-hant.reactjs.org/docs/render-props.html ↩

  4. https://youtu.be/XEt09iK8IXs ↩

Related Posts

Explore Other Topics