React-ish iOS Apps: A Tutorial

Going from modern web frontend (or React Native or Flutter) to native iOS (or MacOS) app work means travelling 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 doesn't allow for composition and scales dreadfully. (Yes, you can take a quarter step into the future with Reactive approaches, MV-VM or whatever but these add masses of complexity and yet can't deliver anything like the dynamicism, 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 rumours 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 code base.

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 behaviours. Our component expects Props and we can use Swift's powerful type system to cleanly model this. 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 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 some 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 re-usable. Testing should be very simple and remain decoupled from actual UI.

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

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

I also created a small API service which works with Swift's automatic Codable conformance to deserialise an API response. You can look at the source code but it isn't so important for the app. The nice bit is how we can cleanly go from state (the APIData) to views without having to write code explicitly for this. We initialise state like 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 at 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 an override var node: AnyNode.

Thoughts

I really like the Tokamak library. It has a beautiful declarative API and remarkably brings in features that have just been released in React. It is still only on version 0.1 but a lot of 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 their legacy UI frameworks to the ground(!) and try to start afresh in order 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 such that community efforts like Tokamak could really succeed.

Alternatives

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

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

See all posts