hrtyy.dev

Experimental Tiny Implementation of Suspense and ErrorBoundary in SwiftUI

Introduction

SwiftUI is a great framework for building user interfaces. It is declarative, reactive, and easy to use. It reduced cost of building UIs and made it easier to build complex UIs.

However, we have to write some boilerplate code to handle asynchronous operations and error handling. See the following example.

1struct TraditionalUserComponent: View {
2 @State var user: User?
3
4 init() {}
5
6 var body: some View {
7 if let user = user {
8 Text("Hi, my name is \(user.name).")
9 } else {
10 ProgressView()
11 .task {
12 user = try! await getUser()
13 }
14 }
15 }
16}
17
18struct ContentView: View {
19 var body: some View {
20 TraditionalUserComponent()
21 }
22}

When this component is rendered, it shows a progress view until the user is fetched. After the request is completed, it shows the user's name.

I defined user as an optional property because it is not determined at the beginning. If I can write this property as a non-optional, we don't need to write the @State propertyWrapper, if-else statement and ProgressView performing some tasks.

I will show you pesudo code of how to write it.

1struct TraditionalUserComponent: View {
2 var user: User
3
4 init() {}
5
6 var body: some View {
7 Text("Hi, my name is \(user.name).")
8 }
9}

It becomes much simpler and easier to understand. But, other problems arise, how to set the user property?

throwable Component

I renamed TraditionalUserComponent to UserComponent and I try to resolve the user property in the initializer.

1struct UserComponent: View {
2 var user: User
3
4 init() {
5 self.user = // getUser() How can I do this??
6 }
7
8 var body: some View {
9 Text("Hi, my name is \(user.name).")
10 }
11}

We need to get the user data without escaping using nil. To do this, I used throws initializer. I permitted components to throw errors in the initializer.

1struct UserComponent: View {
2 var user: User
3
4 init() throws {
5 self.user = try getUser()
6 }
7
8 var body: some View {
9 Text("Hi, my name is \(user.name).")
10 }
11}

Next, I needed to handle the error thrown by the initializer.

Suspense

In React, there is a feature called 'Suspense'. It is a component that allows their children to throw JavaScript Promise. Until the promise is resolved, the fallback component is rendered.

This concept can be applied to SwiftUI. I defined Suspense component in SwiftUI as the following. This component receives a PAGE component that is throwable. When the PAGE component is ready to render, it renders the PAGE component. If not, it renders the fallback component.

1struct Suspense<PAGE: View, A>: View {
2 @State private var page: PAGE?
3 private var component: (A?) throws -> PAGE
4
5 init(@ViewBuilder component: @escaping (A?) throws -> PAGE) {
6 self.component = component
7 }
8
9 var body: some View {
10 if let page = page {
11 page
12 } else {
13 ProgressView()
14 .onAppear {
15 do {
16 page = try component(nil)
17 } catch Promise<A>.pending(let query) {
18 Task {
19 let data = try await query()
20 page = try component(data)
21 }
22 } catch {
23 // Do Nothing
24 }
25 }
26 }
27 }
28}

In the fallback process, it catches the Promise error. It is defined as the following.

1enum Promise<A>: Error {
2 case pending(query: () async throws -> A)
3}

This Promise is throw by the component's initializer like

1struct UserComponent: View {
2 var user: User
3
4 init(user: User?) throws {
5 self.user = try resolveValue(user) {
6 try await getUser()
7 }
8 }
9
10 var body: some View {
11 Text("Hi, my name is \(user.name).")
12 }
13}
14
15func resolveValue<A>(_ pendingA: A?, query: @escaping () async throws -> A) throws -> A {
16 if let value = pendingA {
17 return value
18 } else {
19 throw Promise<A>.pending(query: query)
20 }
21}

As the result, we can display the UserComponent as the following.

1struct ContentView: View {
2 var body: some View {
3 Suspense { user in
4 try UserComponent(user: user)
5 }
6 }
7}

This code looks much declarative and easy to understand comparing to the traditional one.

1struct TraditionalUserComponent: View {
2 @State var user: User?
3
4 init() {}
5
6 var body: some View {
7 if let user = user {
8 Text("Hi, my name is \(user.name).")
9 } else {
10 ProgressView()
11 .task {
12 user = try! await getUser()
13 }
14 }
15 }
16}
17
18struct ContentView: View {
19 var body: some View {
20 TraditionalUserComponent()
21 }
22}

swiftui-suspense

I implemented a tiny library, called 'swiftui-suspense'. This library also has a feature of ErrorBoundary. You can see an example here.

Simply, the following code can be rewritten

1struct TraditionalErrorComponent: View {
2 @State var user: User?
3 @State var error: Error?
4
5 init() {}
6
7 var body: some View {
8 if let error = error {
9 Text("Error happend: \(error.localizedDescription)")
10 } else if let user = user {
11 Text("Hi, my name is \(user.name).")
12 } else {
13 ProgressView()
14 .task {
15 do {
16 user = try await getUser()
17 } catch {
18 self.error = error
19 }
20 }
21 }
22 }
23}
24
25struct TraditionalContentView: View {
26 var body: some View {
27 TraditionalErrorComponent()
28 }
29}

as the following.

1struct NewApproachContentView: View {
2 var body: some View {
3 ErrorBoundary {
4 Suspense { user in
5 try UserComponent(user: user)
6 }
7 }
8 }
9}

Thanks for reading.

References

If you have any questions, please send an email tohoritayuya@gmail.com.