React-ish iOS Apps: A Tutorial

Update 3/8/2019 Well, WWDC and SwiftUI meant this article aged rather badly! Yes, Marzipan (now Catalyst) was just UIKit on the Mac, but something else, SwiftUI, is an actual React-ish UI framework. Interestingly, it also comes with Combine (an Rx-style reactive programming framework). Of course, Apple kind of pretends none of the existing stuff is out there, which is quite ridiculous given how many years behind they are on this stuff.

Unfortunately, the new frameworks are iOS 13+ and macOS 10.15+, i.e., they don't run on anything currently out there. This is typical for Apple but disappointing, as for 'enterprise' development, it means this framework is basically irrelevant for the next two and a bit years. (The Android equivalent, Jetpack Compose does not have this limitation.) So, what to do in the meantime? Probably you should look into React Native or Flutter. Both will allow you to do modern UI programming on iOS today.

I'm probably going to be updating some of my iOS apps (including at least Isometrically and Code Sculpture) when SwiftUI lands. I might even do Mac versions. But these will be (effectively) forks, supporting only iOS 13+. Anyway, on to the original article.

React-ish iOS Apps: A Tutorial

Going from modern web frontends (or React Native or Flutter) to native iOS (or macOS) app work means traveling back in time—from quasi-functional, clean separation of state and views via components (or 'widgets') and the possibility of a Model-View-Update structure, to something a lot more imperative. In iOS we typically have to take care of state transitions manually. This approach doesn't allow for composition and scales dreadfully. (Yes, you can take a quarter step into the future with reactive approaches, MVVM, or whatever, but these add masses of complexity and yet can't deliver anything like the dynamism, flexibility, and clarity of a React-ish approach). Out of the box, iOS UI frameworks are several years behind the state-of-the-art elsewhere.

Initial rumors of Project Marzipan suggested this might be addressed with a modern, cross-platform, React-ish UI framework. Disappointingly, the reality appears to be a fairly crude port of UIKit to macOS. So, what to do if you want to do modern UI development on mobile? Well, the real answer is probably to use React Native or Flutter. They are pretty great and mean you can deliver to both major platforms from one almost entirely shared codebase.

But let's assume you are just on iOS and want to use Swift. Unfortunately, while there are quite a few options, they are fairly experimental. (And I expect that without some kind of official support, they are unlikely to be able to compete with RN/Flutter; for example, hot/live reload for fast iteration seems unattainable.) I had a look at the options (in April 2019), and Tokamak seems like the most promising. It has a great declarative API and even supports some React Hooks-like functionality.

Let's get started.

Create a new iOS project and create a Podfile

target 'TokamakExample' do
  use_frameworks!

  pod 'TokamakUIKit', '~> 0.1'
end

Install with pod install and open the workspace.

A Simple Example

Let's consider a simple example of stateful UI in Tokamak. There are two key concepts (which should be familiar if you are used to React): Components and Props. Components are parts of an app: they might just be UI, but could also have some state and behaviors. Our component expects props, and we can use Swift's powerful type system to model this cleanly. A simple example is a button:

Button.node(Button.Props(
  onPress: Handler { delta.set { $0 + 1 } },
  text: "Change Delta (\(delta.value))"
))

We pass in Props: a label and event handler. Unlike React, Tokamak doesn't use JSX; Swift works fine. Notice how we describe the UI rather than giving instructions on how to build it. But what about state? How does that Handler thing work?

We use a React Hooks-like API to create state that is then mutated. let delta = hooks.state(1) sets up a piece of state, and delta.set { $0 + 1 } updates it. We then use the nice declarative API to describe the layout. The framework will do the messy updating of views from state updates; we don't write that tedious, error-prone code.

Okay, but where does all that code go? Again, it is similar to React: we have a render function: static func render(props: Props, hooks: Hooks) -> AnyNode. In this function, we set up hooks as above and return a node.

Here is a fuller example:

import Tokamak

struct Counter: LeafComponent {
    struct Props: Equatable {
        let countFrom: Int
    }

    static func render(props: Props, hooks: Hooks) -> AnyNode {
        let count = hooks.state(props.countFrom)
        let delta = hooks.state(1)

        return StackView.node(.init(
            Edges.equal(to: .parent),
            axis: .vertical,
            distribution: .fillEqually), [
                Button.node(Button.Props(
                    onPress: Handler { count.set { $0 + delta.value } },
                    text: "Increment"
                )),
                Button.node(Button.Props(
                    onPress: Handler { count.set { $0 - delta.value } },
                    text: "Decrement"
                )),
                Button.node(Button.Props(
                    onPress: Handler { delta.set { $0 + 1 } },
                    text: "Change Delta (\(delta.value))"
                )),
                Label.node(.init(alignment: .center, text: "\(count.value)"))
            ])
    }
}

The App

We'll try and make something vaguely real: we'll call an API, get a response, show a loading screen while fetching, show the results in a list, and allow users to open items.

The resulting code is very naturally structured and reusable. Testing should be very simple and remain decoupled from the actual UI.

I set up an enum for concisely, clearly, and only-correctly(!) modeling the state:

enum APIData {
    case notAsked
    case loading
    case loaded(data: [Update])
    case error(message: String)
}

I also created a small API service that works with Swift's automatic Codable conformance to deserialize an API response. You can look at the source code, but it isn't so important for the app. The nice part is how we can cleanly go from state (the APIData) to views without having to write code explicitly for this. We initialize state as before with let state = hooks.state(APIData.notAsked). Notice how we can use the Swift enum here.

I also expose a title setter from the hosting ViewController, but for testing purposes, an actual ViewController is not necessary.

The main part is just mapping from state to view, switching over the APIData enum. I really like this style of code, as it can ensure that we deal with each case, and if we ever make changes, we deal with the new cases.

UpdatesList.swift:

import Tokamak

let basicStyle = Style(Edges.equal(to: .safeArea, inset: 20))

struct UpdatesList: LeafComponent {
    struct Props: Equatable {
        static func == (lhs: UpdatesList.Props, rhs: UpdatesList.Props) -> Bool {
            return lhs.uuid == rhs.uuid
        }

        let uuid = UUID().uuidString
        let setTitleFor: (APIData) -> Void
        let showDetail: (Update) -> Void
    }

    static func render(props: Props, hooks: Hooks) -> AnyNode {
        let state = hooks.state(APIData.notAsked)
        hooks.effect {
            API.refreshData { newState in
                state.set(newState)
                props.setTitleFor(newState)
            }
        }

        switch state.value {
        case .notAsked:
            return Label.node(.init(
                basicStyle,
                alignment: .center,
                text: "Not Asked"
                ))
        case .error(let message):
            return Label.node(.init(
                basicStyle,
                alignment: .center,
                text: message
                ))
        case .loading:
            return Label.node(.init(
                basicStyle,
                alignment: .center,
                text: "Loading"
                ))
        case .loaded(let data):
            return StackView.node(.init(
                Edges.equal(to: .parent),
                axis: .vertical,
                distribution: .fillEqually), [
                    List.node(List.Props(
                        model: data,
                        onSelect: Handler { idx in
                            props.showDetail(data[idx.item])
                        }
                    ))
                ]
            )
        }
    }
}

You can see the full code in my Tokamak example repo. I actually didn't use Tokamak for the detail view, as WKWebViews aren't wrapped (yet). It is worth noting how little code is required to connect the component to the view controller: by subclassing TokamakViewController, all we need to do is implement override var node: AnyNode.

Thoughts

I really like the Tokamak library. It has a beautiful declarative API and remarkably brings in features that had just been released in React. It is still only on version 0.1, but many components are in place. The documentation is limited, but there are a decent number of examples. Some of the errors from the Swift compiler aren't great, but that is mostly a Swift compiler fault.

I would really love to see Apple burn its legacy UI frameworks to the ground(!) and try to start afresh to catch up with Facebook (React/React Native) and Google (Flutter). That seems extraordinarily unlikely, but they could certainly build the new cross-platform React-ish framework that we hoped Marzipan might be. Or at least put in place the foundations so that community efforts like Tokamak could really succeed.

Alternatives

Square's Blueprint might also be worth a look. It's from a major company and is apparently used in their actual app.

All of the other projects I found seemed not to have been updated to Swift 4.2 (and in some cases, not for a very long time), confirming their experimental appearance.