DEV Community

loading...
Cover image for Managing View State With Combine

Managing View State With Combine

Omar ๐Ÿ‘พ
๐Ÿง‘โ€๐Ÿ’ป Software engineer. I write about Swift development, architectural patterns and clean code
ใƒป8 min read

Data Flow

SwiftUI's built in state management system does a great a job in providing an out of the box data flow solution that alleviates a lot of the pain points associated with manually updating UI based on state changes.

As your codebase grows and you find yourself leaning towards incorporating common architectural patterns, you'll likely come across the need to separate some state specific logic into a view model. Swift's Combine framework provides some great property wrappers to ease this transition via @PublishSubject and @ObservedObject.

There are some great articles that go into depth about these topics. I highly suggest you check them out if you aren't yet familiar with these concepts:

Continuing where the articles left off, I've put together a TodoListView that observes and reacts to a ViewState published by its ViewModel:

struct TodoListView: View {
    @ObservedObject
    private var viewModel = ViewModel()
    var body: some View {
        content(viewModel.viewState)
            .onAppear {
                viewModel.loadTodos()
            }
    }

    @ViewBuilder
    private func content(_ state: ViewState) -> some View {
        switch state {
        case .loading:
            ProgressView()
        case .error:
            Text("Oops something went wrong")
                .padding()
        case .content(let todos):
            List {
                ForEach(todos) { todo in
                    Text(todo.title)
                }
            }
        }
    }
}

extension TodoListView {
    enum ViewState {
        case loading
        case content(todos: [Todo])
        case error
    }

    class ViewModel: ObservableObject {
        @Published
        var viewState: ViewState = .loading
        private let todoRepository = TodoRepistory()
        private var cancellables = Set<AnyCancellable>()

        func loadTodos() {
            self.viewState = .loading
            todoRepository.getTodos()
                .sink { [weak self] completion in
                    switch completion {
                    case .failure:
                        self?.viewState = .error
                    case .finished:
                        ()
                    }
                } receiveValue: { [weak self] todos in
                    self?.viewState = .content(todos: todos)
                }
                .store(in: &cancellables)
        }

        // ....
    }
}
Enter fullscreen mode Exit fullscreen mode

This setup already has some great benefits:

  • Our View is free of any state management logic. This allows us to unit test and even share that logic between other views
  • By conforming to the ObservableObject protocol and leveraging the @Published property wrapper, we've managed to make that separation easily consumable by our View. It works very similar to reacting off a @State property
  • We've got a nice repeatable pattern that we can apply to future views that require complex logic

While we can definitely call it a day and move on, you might find yourself wondering what other patterns can we apply here that further adhere to the reactive mantra

I want to highlight that what I'm about to showcase is pretty much a Combine + SwiftUI remix of Jake Wharton's outstanding talk Managing State with RxJava. It's about an hour but I can't emphasize enough how amazing his presentation is and how much it inspired me to apply reactive programming to more than just networking and repository logic. It's in Java but thanks to the language's verbosity it's possible to follow along.

Room for improvement

Looking at the current state of our view model there are a few things that seem unnatural:

func loadTodos() {
    self.viewState = .loading
    todoRepository.getTodos()
        .sink { [weak self] completion in
            switch completion {
            case .failure:
                self?.viewState = .error
            case .finished:
                ()
            }
        } receiveValue: { [weak self] todos in
            self?.viewState = .content(todos: todos)
        }
        .store(in: &cancellables)
}
Enter fullscreen mode Exit fullscreen mode

Setting state outside of the stream seems misplaced and requires one to remember to revert the loading state during success and failure

// ..
func addTodo(title: String) {
    todoRepository.addTodo(title: title)
        .sink { [weak self] completion in
            switch completion {
            case .failure:
                self?.viewState = .error
            case .finished:
                ()
            }
        } receiveValue: { [weak self] todo in
            if case .content(var todos) = self?.viewState {
                todos.append(todo)
                self?.viewState = .content(todos: todos)
            }
        }
        .store(in: &cancellables)
}

func removeTodo(id: String) {
    todoRepository.removeTodo(id: id)
        .sink { [weak self] completion in
            switch completion {
            case .failure:
                self?.viewState = .error
            case .finished:
                ()
            }
        } receiveValue: { [weak self] _ in
            if case .content(var todos) = self?.viewState {
                todos.removeAll(where: { $0.id == id})
                self?.viewState = .content(todos: todos)
            }
        }
        .store(in: &cancellables)
}
// ..
Enter fullscreen mode Exit fullscreen mode

As our view model grows in functionality, you'll notice our code will quickly get heavy with boilerplate subscription code. While we could clean this up and utilize theassign(to:) operator, they'd still be another peculiar characteristic of our state management; the lack of a centralized view state.

untitled@2x.png

Redux/Flux's unidirectional data flow pattern solves a lot of the complexities of having state mutated throughout various areas in the codebase, yet here we are updating our view state throughout various areas of our view model.

How can we adjust our state management approach to alleviate these concerns?

Inputs, Actions and ViewState

Looking at our TodoListView from a behavioral point of view, we can quickly identify some common cause and effect patterns. When a user loads the view, they'd request a list of Todos. When a user adds a Todo, the list will update with the newly added list etc. We could summarize that a user triggered input is causing our app or view model to respond and react to it, resulting in an updated view state.

untitled@2x (1).png

This sort of pattern is reactive in nature. Inputs can be happening in more than one place at a time and processing those inputs might require async operations. Reactive frameworks like Combine were made for these sort of circumstances and come with some handy operators that can help us gel this pattern into a consumable reactive state.

Let's see what an implementation that incorporates those operators might look like:

extension TodoListView {
    enum ViewState {
        case loading
        case content(todos: [Todo])
        case error
    }

    enum Input {
        case stateRequested
        case todoAdded(title: String)
        case todoRemoved(id: String)
    }

    enum Action {
        case setState(ViewState)
        case addTodo(todo: Todo)
        case removeTodo(id: String)
    }

    class ViewModel: ObservableObject {
        @Published
        var viewState: ViewState = .loading
        private let todoRepository = TodoRepistory()
        private let inputSubject = PassthroughSubject<Input, Never>()
        private var cancellables = Set<AnyCancellable>()

        init() {
            inputSubject
                .flatMap { [todoRepository] input -> AnyPublisher<Action, Never> in
                    switch input {
                    case .stateRequested:
                        return todoRepository.getTodos()
                            .map { todos -> Action in
                                .setState(.content(todos: todos))
                            }
                            .replaceError(with: .setState(.error))
                            .eraseToAnyPublisher()
                    case .todoAdded(let title):
                        return todoRepository.addTodo(title: title)
                            .map { todo -> Action in
                                .addTodo(todo: todo)
                            }
                            .replaceError(with: .setState(.error))
                            .eraseToAnyPublisher()
                    case .todoRemoved(let id):
                        return todoRepository.removeTodo(id: id)
                            .map { _ -> Action in
                                .removeTodo(id: id)
                            }
                            .replaceError(with: .setState(.error))
                            .eraseToAnyPublisher()
                    }
                }
                .scan(viewState) { (currentState, action) -> ViewState in
                    switch action {
                    case .setState(let state):
                        return state
                    case .addTodo(let todo):
                        if case .content(var todos) = currentState {
                            todos.append(todo)
                            return .content(todos: todos)
                        }
                        return currentState
                    case .removeTodo(let id):
                        if case .content(var todos) = currentState {
                            todos.removeAll(where: { $0.id == id })
                            return .content(todos: todos)
                        }
                        return currentState
                    }
                }
                .receive(on: DispatchQueue.main)
                .assign(to: &$viewState)
        }

        func send(_ input: Input) {
            inputSubject.send(input)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

There are quite a few things going on here, so let's walk through them step by step.

We first create an entity to describe possible inputs that can occur to our view:

    enum Input {
        case stateRequested
        case todoAdded(title: String)
        case todoRemoved(id: String)
    }
Enter fullscreen mode Exit fullscreen mode

These inputs can end up affecting our view state. Let's call these effects actions:

    enum Action {
        case setState(ViewState)
        case addTodo(todo: Todo)
        case removeTodo(id: String)
    }
Enter fullscreen mode Exit fullscreen mode

In order to process these inputs in a reactive stream, we create a PassthroughSubject, which is a subject that broadcasts elements downstream. This works well for our use case as we would like to channel inputs downstream to be processed into possible actions:

private let inputSubject = PassthroughSubject<Input, Never>()
Enter fullscreen mode Exit fullscreen mode

When we init our view model, we subscribe to these emissions and process them via the flatMap operator. Each input is processed differently. This is why representing our inputs as an enum helps us make sure we account for every possible variation and will remind us in the future to account for newly added inputs:

inputSubject
    .flatMap { [todoRepository] input -> AnyPublisher<Action, Never> in
        switch input {
        case .stateRequested:
            return todoRepository.getTodos()
                .map { todos -> Action in
                    .setState(.content(todos: todos))
                }
                .replaceError(with: .setState(.error))
                .eraseToAnyPublisher()
        case .todoAdded(let title):
            return todoRepository.addTodo(title: title)
                .map { todo -> Action in
                    .addTodo(todo: todo)
                }
                .replaceError(with: .setState(.error))
                .eraseToAnyPublisher()
        case .todoRemoved(let id):
            return todoRepository.removeTodo(id: id)
                .map { _ -> Action in
                    .removeTodo(id: id)
                }
                .replaceError(with: .setState(.error))
                .eraseToAnyPublisher()
        }
    }
Enter fullscreen mode Exit fullscreen mode

Take note that we are transforming our stream here into a publisher of <Action, Never>. This means that regardless of the input, the outcome of this step should result in one or many Actions. The Never lets the compiler know that we don't intend this subscription to ever error out. In theory it shouldn't. Any error that might occur in processing should be handled and accounted for(i.e displaying an error state in the view).

This is where the replaceError(with:)operator comes in to save the day. We can easily transform unexpected errors into a relevant action that will get applied to our view state. It's pretty neat and It always forces us to account for unhappy paths when putting together the view model logic.

The final piece to the puzzle is the scan operator. Each action can have an effect on our view state. Via the scan operator, we can process each action as there are emitted by publishers upstream and produce a new view state that we then assign to our @Published view state.

.scan(viewState) { (currentState, action) -> ViewState in
    switch action {
    case .setState(let state):
        return state
    case .addTodo(let todo):
        if case .content(var todos) = currentState {
            todos.append(todo)
            return .content(todos: todos)
        }
        return currentState
    case .removeTodo(let id):
        if case .content(var todos) = currentState {
            todos.removeAll(where: { $0.id == id })
            return .content(todos: todos)
        }
        return currentState
    }
}
.assign(to: &$viewState)
Enter fullscreen mode Exit fullscreen mode

If you are familiar with reducers, the above should look familiar. Instead of reducing multiple actions into one state, we are processing each action and producing a new state.

As for our TodoListView, the only change would be in how our view channels inputs to our view model via the send(_ input: Input) method:

// ...
content(viewModel.viewState)
    .onAppear {
        viewModel.send(.stateRequested)
    }
// ...
Enter fullscreen mode Exit fullscreen mode

And there we have it. A view state management setup thats a bit more reactive, maybe too reactive.

Some benefits to this approach:

  • Updates to view state are centralized in one area of the view model. This allows to leverage operators like .receive(on:) and removeDuplicates() to insure updates are on the main thread and avoid unnecessary view rebuilding
  • As we add more inputs and actions, the compiler will complain until you account for them.
  • The pattern forces you to account for both the happy and not so happy paths.
  • And more that i'll explain in my next post.

There's quite a bit of stuff going on with this approach. I was super confused when I was first introduced to these concepts. If you find yourself scratching your head, I highly recommend you give the video by Jake a look, as he does a great job in explaining the concept in a lot more detail.

I hope you found this article useful. This is my first attempt at blogging, so any feedback and suggestions would be super useful. Follow me on Twitter for future updates and articles.

In my next post, I'll go over some of the extra benefits of this pattern, namely side effects and how they fit well for related tasks such as event tracking. I'll also share some tips on how to make this approach a lot less boilerplate

stay tuned.

Discussion (0)