Introduction
I have limited experience in iOS development, mobile development, and SwiftUI. If there are any misunderstandings, please correct me.
From a UI perspective, frontend and mobile development face similar problems, even though they use different languages or development approaches. We all need to build a user-friendly interface. Therefore, we encounter similar issues such as component-based development, state management, data flow, managing side effects (API or IO), etc. It is a field that is suitable for mutual learning.
From past experiences, we can see that libraries like ReSwift (based on the central idea of Redux) have also drawn inspiration from the evolving development approaches in frontend. It is not difficult to see that the problems encountered by both sides are actually similar.
Although it may be a bit late to bring up at this point, I still want to share my thoughts after getting started with SwiftUI.
Similarities between SwiftUI and React
We can categorize frontend frameworks into several elements:
- Componentization
- Reactive mechanism
- State management
- Event listening
- Lifecycle
In the following paragraphs, we will discuss these topics as the core. In order to stay focused, I will try to use React as an example whenever possible, but the principles should apply to other frontend frameworks as well.
From Class to Struct; From Class to Function
When writing SwiftUI, it always reminds me of the history of React's development. Initially, React built components using JavaScript's class syntax, where each React component was a 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>
}
}
Although defining components as classes brought significant impact to frontend componentization, it also introduced complexity with method definitions and confusion with this
. With the introduction of React 16 hooks, there was a gradual shift towards using function components and hooks to build components.
By eliminating the need for inheritance and various fancy design patterns of object-oriented programming, the mental burden of constructing components became smaller. From SwiftUI, we can also see a similar evolution. The previously massive classes and responsibilities of ViewControllers, which were responsible for the interaction between views and models, as well as managing lifecycles, have transformed into lightweight structs. This allows developers to focus more on UI interactions and reduce cognitive load.
Component State Management
React 16 introduced 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 achieve a similar effect with the @State
property wrapper. Both have a reactive mechanism, where changes in state variables are detected and reflected in the UI by React/Vue. Although I'm not sure about the implementation behind SwiftUI, there should be something similar to a diffing mechanism to achieve reactive updates and minimize unnecessary updates.
However, there are still differences between SwiftUI's state management and React hooks. In React, we can separate hooks into separate functions and use them in different components. For example:
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 use it in different components. Since useToggle
is a pure function, the internal state does not affect each other.
However, in SwiftUI, @State
can only be applied to private variables in structs and cannot be further extracted. If we want to extract repetitive logic, we need to use modifiers like @Observable
and @StateObject
to create a separate class to handle 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 example, it seems a bit excessive to separate the toggle logic into a class. However, thinking carefully, just like the hook functionality provided by React, it is not too verbose to split lightweight logic even if it is separated into a hook. If you need to encapsulate more complex logic, you can also split it into more hooks. From this perspective, hooks are indeed an excellent mechanism. I wonder if SwiftUI has a similar mechanism. 1
In terms of React, before the introduction of hooks, there were mainly three ways to implement logic reuse:
- HOC (Higher Order Component) 2: Wrap common logic into a function and return a brand new class, avoiding direct modifications to the implementation of the component. For example,
connect
in the earlyreact-redux
. - Render props 3: Pass the actual rendering component as a prop and provide the necessary parameters for the implementation.
- Children function: Pass only the necessary parameters to children, and let the implementation decide which component to render.
Although hooks elegantly solve the problem of logic reuse, I think the evolution of development approaches mentioned above is also worth considering.
Redux and TCA
Influenced by Redux, some developers in Swift have also adopted similar approaches, and there are even corresponding implementations like ReSwift with explanations. From the explanations, we can see the main reasons. Traditional ViewControllers have ambiguous responsibilities and can become bloated, making them difficult to maintain. By ensuring a unidirectional data flow through reducers, actions, and store subscriptions, all operations are dispatched to the store as actions, and mutations are handled in reducers.
Recently, the trend seems to have shifted from Redux to TCA (The Composable Architecture), which has a similar central idea to Redux but integrates more easily with SwiftUI. The main difference is that in Redux, side effects are usually handled by middleware, while in the TCA architecture, a reducer can return an Effect, which represents the IO operations or API calls to be executed when receiving an action.
Since a similar Redux approach is adopted, I wonder if SwiftUI will encounter similar problems as frontend development, such as immutability to ensure updates are observable, optimizing the subscription mechanism to ensure that only corresponding components are updated when the store changes, and the boilerplate issues caused by reducers and actions.
Although Redux still holds a certain position in frontend development and many companies are adopting it, there is an increasing number of voices abandoning Redux, mainly due to Redux's pursuit of pure functions and the high redundancy of reducers and actions. Even the author of Redux himself has started to move away from Redux. 4 Meanwhile, react-redux is still being actively maintained and has introduced Redux Toolkit to address common issues when using Redux.
Instead, lighter state management mechanisms have emerged in frontend development, and several genres have been derived:
Global State Management
In terms of global state management, SwiftUI also provides a built-in mechanism called @EnvironmentObject
, which works similarly to React's context. It allows components to access variables across different layers, and when the context changes, the components will also update.
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().environmentObject(User())
In the above example, we don't need to pass user
to UserInfo
separately. Through @EnvironmentObject
, we can access the current context. In React, it would look like this:
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's context allows components to access variables across different layers, and when the context changes, the components will also update. Although it effectively avoids the issue of prop drilling, the presence of context makes testing more complicated because using context implies a certain degree of coupling.
Reactive Mechanism
In React, components are updated when there are changes in state or props, and the framework's diffing mechanism calculates the minimal differences and updates the UI accordingly. In SwiftUI, we can also observe a similar mechanism:
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 determines the UI through the body
variable. Similarly to React, they are merely abstract descriptions of the UI. By comparing data structures and calculating minimal differences, the updates are then reflected in the UI.
@State
is used to define internal component state in SwiftUI. When the state changes, it will be updated and reflected in the UI.
When writing SwiftUI, you may notice that it is different from the traditional development approach using UIKit and UIController.
Lists
Both SwiftUI and React can render lists, and the way of writing them also has similarities. In SwiftUI, it can be written like this:
struct TextListView: View {
var body: some View {
List {
ForEach([
"iPhone",
"Android",
"Mac"
], id: \.self) { value in
Text(value)
}
}
}
}
In React, it would be something like this:
const TextList = () => {
const list = ['iPhone', 'Android', 'Mac']
return list.map(item => <p key={item}>{item}</p>)
}
When rendering lists, to ensure performance and minimize unnecessary comparisons, React requires developers to provide a unique key, and SwiftUI has a similar mechanism. Developers must implement the Identifiable
protocol or explicitly pass an id
.
Binding
In addition to binding variables to the UI, we can also bind interactions to variables. For example, 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, even without listening to input events, using $text
can directly modify the text
variable. When using @State
, it adds a property wrapper that automatically adds a $
prefix, creating a Binding type.
React does not have a two-way binding mechanism, so input events must be explicitly listened to ensure a unidirectional data flow. However, frameworks like Vue and Svelte have two-way binding mechanisms, which save developers from manually listening to events.
Introduction of Combine
Although I'm not very familiar with Combine, from the official documentation and videos, it seems like a Swift-specialized version of RxJS, providing APIs and operators that greatly simplify complex data flows. This reminds me of the time when I studied various fancy operations with RxJS and redux-observable. It's truly nostalgic.
Fundamental Differences
After mentioning so much, there are still significant differences between web and mobile development, with the most prominent being static compilation vs. dynamic execution. Dynamic execution can be said to be one of the biggest features of the web.
As long as there is a browser, JavaScript, HTML, and CSS, it can be executed successfully on any device. Websites do not need to download 1xMB to several hundred MB of content in advance and can execute scripts dynamically, loading content based on the visited page.
Since there is no need for precompilation, anyone can see the content and execute scripts on a website. Additionally, HTML has the ability to stream, allowing content to be rendered while being loaded. The remarkable thing is that websites are decentralized. As long as there is a server, IP address, and domain, anyone can access the website. In contrast, apps must go through an approval process before being published.
However, the ecosystems and development approaches of the two still have significant differences. It is still recommended to learn from each other's development and perspectives. Even if you don't usually work with them, looking at things from different angles often reveals different insights and helps cultivate technical acumen.
Footnotes
-
Later, I came across SwiftUI-Hooks, but I'm not sure about its actual usage and effects. ↩