Smart and Dumb views in SwiftUI

Tue, 6 Jul 2021 00:00:00 +0000

Let's see how organizing our views into stateful and stateless views can greatly simplify our codebase.

Making a SwiftUI view is nothing but simple. It just takes a few lines of code.

Let's take this view for instance:

struct MessageScreen: View {
    @StateObject var viewModel = MessageViewModel()

    var body: some View {
        List {
            ForEach(viewModel.messages) { message in
                HStack {
                    Button(action: { viewModel.edit(message) }) {
                        VStack {
                            Text(message.sentAt.formatted())
                                .font(.footnote)
                            Text(message.content)
                        }
                    }

                    Spacer()

                    Button(action: { viewModel.delete(message) }) {
                        Image(systemName: "minus.circle.fill")
                    }
                }
            }
        }
        .onAppear(perform: viewModel.loadMessages)
        .navigationTitle("Mesages")
    }
}

At first glance there is not much to to say about. It is quite simple and functional. A very classic SwiftUI view.

Still, we can notice we are doing two things inside it:

🎨 Layout, as we loop over viewModel.messages to display them

⌨️ Data handling through onAppear and @StateObject

Beside this mixing we are also hitting a limit: we can't display a list of messages without first loading them. Neither can we make multiple previews without instantating a ViewModel which might require multiple dependencies.

struct MessageScreen_Previews: PreviewProvider {
    static var previews: some View {
        // How to display view in multiple states?
        // How to avoid to instantiate all (potentialy numerous) ViewModel dependencies?
        MessageScreen()
    }
}

To bypass these issues let's refactor our view into a smart and dumb component.

Smart or dumb?

You probably already heard about this pattern as it is quite popular in React ecosystem. If not Smart and Dumb components (also known as Container and Rendering views or Container and Presentational views) are a way to split your views into two categories depending on their state.

Smart views are stateful: they handle data state and view lifecycle. They don't manage layout and thus delegate this task to their sibling: dumb views.

Dumb views are used to render data only. In effect they receive data through init and thus don't store any internal state. Therefore compared to smart views they are stateless.

In iOS world these concepts can be mapped to Property wrappers.

| Smart view | Dumb view
@State | ✅ | ❌
@StateObject | ✅ | ❌
@ObservedObject | ✅ | ❌
@Binding | ✅ | ✅
@EnvironmentObject | ✅ | ❌
@Environment | ✅ | ✅
@Namespace | ✅ | ❌

As (most) property wrappers indeed lead to state handling you should use them only in smart views. Exceptions being Environment and Binding.

Bindings are fine because they are equivalent as passing two functions, get: () -> T and set: T -> ().

Environment is fine as it does not incur any state.

Time to compose

Let's try applying this approach to our example by first extracting our layout into a new view:

struct MessageListView: View {
    let messages: [Message]
    let edit: (Message) -> Void
    let delete: (Message) -> Void

    var body: some View {
        List {
            ForEach(messages, content: row(message:))
        }
    }

    @ViewBuilder
    func row(message: Message) -> some View {
        HStack {
            Button(action: { edit(message) }) {
                VStack {
                    Text(message.sentAt.formatted())
                        .font(.footnote)
                    Text(message.content)
                }
            }

            Spacer()

            Button(action: { delete(message) }) {
                Image(systemName: "minus.circle.fill")
            }
        }
    }
}

Important thing to note is that this view has no state: messages and actions are passed at initialization. Therefore we can consider this view as being a dumb view.

Passing closures to dumb views to execute an action is a very common scenario.

We then update MessageScreen by invoking our new MessageListView:

struct MessageScreen: View {
    @StateObject var viewModel = MessageViewModel()

    var body: some View {
        MessageListView(
            messages: viewModel.messages,
            edit: viewModel.edit,
            delete: viewModel.delete
        )
        .onAppear(perform: viewModel.loadMessages)
        .navigationTitle("Messages")
    }
}

MessageScreen still reference StateObject and onAppear so it is definitely handling some state and can be called a smart view.

We can now have two views, one handling view state and the other one rendering it. At this point you might wonder though: so what? What did we gain?

What's the point?

At first glance it might seem we did not bring much value to our code. We even increased code size and went from one to two views!

However taking a closer look we can see:

♻️ We gained a reusable view (MessageListView)

👓 We can easily preview our dumb view

✂️ Last but not least most of our code is tech agnostic. Whether you decided to go with a ViewModel, Redux or Composable Architecture, your dumb view does not depend on it. Therefore you can switch your architecture without impacting it!

// We can now easily make previews of our view
struct MessageList_Previews: PreviewProvider {
    static var previews: some View {
        MessageListView(
            messages: [
                .init(id: UUID(), content: "Welcome on Swift Unwrap", sentAt: Date())
            ],
            edit: { _ in },
            delete: { _ in }
        )
    }
}

Conclusion

Smart/Dumb components is very simple and is just a way of thinking how to organize your state in your views.

Use it wisely (make few smart views and a lof of dumb views) and you will avoid many data synchronization pitfallss and reduce view coupling to your architecture. So for once, don't try to be smart, be dumb!