Untitled
  • June 15, 2025

Tempura is a holistic approach to iOS development, it borrows concepts from Redux (through Katana) and MVVM.

State of the project


We wrote several successful applications using the layer that Katana and Tempura provide. We still think that their approach is really a good one for medium-sized applications but, as our app grows, it becomes increasingly important to have a more modular architecture. For this reason, we have migrated our applications to use The Composable Architecture.

🎯 Installation


Tempura is available through CocoaPods.

Requirements

  • iOS 11+
  • Xcode 11.0+
  • Swift 5.0+

CocoaPods

CocoaPods is a dependency manager for Cocoa projects. You can install it with the following command:

$ sudo gem install cocoapods

To integrate Tempura in your Xcode project using CocoaPods you need to create a Podfile with this content:

use_frameworks!
source ‘https://cdn.cocoapods.org/’
platform :ios, ‘11.0’

target ‘MyApp’ do
pod ‘Tempura’
end

Now you just need to run:

$ pod install

Swift Package Manager

Since version 9.0.0Tempura also supports Swift Package Manager (SPM).

🤔 Why should I use this?


Tempura allows you to:

  1. Model your app state
  2. Define the actions that can change it
  3. Create the UI
  4. Enjoy automatic sync between state and UI
  5. Ship, iterate

We started using Tempura in a small team inside Bending Spoons. It worked so well for us, that we ended up developing and maintaining more than twenty high quality apps, with more than 10 million active users in the last year using this approach. Crash rates and development time went down, user engagement and quality went up. We are so satisfied that we wanted to share this with the iOS community, hoping that you will be as excited as we are. ❤️

Splice Thirty Day Fitness Pic Jointer Yoga Wave

👩‍💻 Show me the code


Tempura uses Katana to handle the logic of your app. Your app state is defined in a single struct.

struct AppState: State {

var items: [Todo] = [
Todo(text: “Pet my unicorn”),
Todo(text: “Become a doctor.\nChange last name to Acula”),
Todo(text: “Hire two private investigators.\nGet them to follow each other”),
Todo(text: “Visit mars”)
]
}

You can only manipulate state through State Updaters.

struct CompleteItem: StateUpdater {
var index: Int

func updateState(_ state: inout AppState) {
state.items[index].completed = true
}
}

The part of the state needed to render the UI of a screen is selected by a ViewModelWithState.

struct ListViewModel: ViewModelWithState {
var todos: [Todo]

init(state: AppState) {
self.todos = state.todos
}
}

The UI of each screen of your app is composed in a ViewControllerModellableView. It exposes callbacks (we call them interactions) to signal that a user action occurred. It renders itself based on the ViewModelWithState.

class ListView: UIView, ViewControllerModellableView {
// subviews
var todoButton: UIButton = UIButton(type: .custom)
var list: CollectionView<TodoCell, SimpleSource<TodoCellViewModel>>

// interactions
var didTapAddItem: ((String) -> ())?
var didCompleteItem: ((String) -> ())?

// update based on ViewModel
func update(oldModel: ListViewModel?) {
guard let model = self.model else { return }
let todos = model.todos
self.list.source = SimpleSource<TodoCellViewModel>(todos)
}
}

Each screen of your app is managed by a ViewController. Out of the box it will automatically listen for state updates and keep the UI in sync. The only other responsibility of a ViewController is to listen for interactions from the UI and dispatch actions to change the state.

class ListViewController: ViewController<ListView> {
// listen for interactions from the view
override func setupInteraction() {
self.rootView.didCompleteItem = { [unowned self] index in
self.dispatch(CompleteItem(index: index))
}
}
}

Note that the dispatch method of view controllers is a bit different than the one exposed by the Katana store: it accepts a simple Dispatchable and does not return anything. This is done to avoid implementing logic inside the view controller.

If your interaction handler needs to do more than one single thing, you should pack all that logic in a side effect and dispatch that.

For the rare cases when it’s needed to have a bit of logic in a view controller (for example when updating an old app without wanting to completely refactor all the logic) you can use the following methods:

  • open func __unsafeDispatch<T: StateUpdater>(_ dispatchable: T) -> Promise<Void>
  • open func __unsafeDispatch<T: ReturningSideEffect>(_ dispatchable: T) -> Promise<T.ReturningValue>

Note however that usage of this methods is HIGHLY discouraged, and they will be removed in a future version.

Navigation

Real apps are made by more than one screen. If a screen needs to present another screen, its ViewController must conform to the RoutableWithConfiguration protocol.

extension ListViewController: RoutableWithConfiguration {
var routeIdentifier: RouteElementIdentifier { return “list screen”}

var navigationConfiguration: [NavigationRequest: NavigationInstruction] {
return [
.show(“add item screen”): .presentModally({ [unowned self] _ in
let aivc = AddItemViewController(store: self.store)
return aivc
})
]
}
}

You can then trigger the presentation using one of the navigation actions from the ViewController.

self.dispatch(Show(“add item screen”))

Learn more about the navigation here

ViewController containment

You can have ViewControllers inside other ViewControllers, this is useful if you want to reuse portions of UI including the logic. To do that, in the parent ViewController you need to provide a ContainerView that will receive the view of the child ViewController as subview.

class ParentView: UIView, ViewControllerModellableView {
var titleView = UILabel()
var childView = ContainerView()

func update(oldModel: ParentViewModel?) {
// update only the titleView, the childView is managed by another VC
}
}

Then, in the parent ViewController you just need to add the child ViewController:

class ParentViewController: ViewController<ParentView> {
let childVC: ChildViewController<ChildView>!

override func setup() {
self.childVC = ChildViewController(store: self.store)
self.add(childVC, in: self.rootView.childView)
}
}

All the automation will work out of the box. You will now have a ChildViewController inside the ParentViewController, the ChildViewController’s view will be hosted inside the childView.

📸 UI Snapshot Testing


Tempura has a Snapshot Testing system that can be used to take screenshots of your views in all possible states, with all devices and all supported languages.

Usage

You need to include the TempuraTesting pod in the test target of your app:

target ‘MyAppTests’ do
pod ‘TempuraTesting’
end

Specify where the screenshots will be placed inside your plist :

UI_TEST_DIR: $(SOURCE_ROOT)/Demo/UITests

In Xcode, create a new UI test case class:

File -> New -> File... -> UI Test Case Class

Here you can use the test function to take a snapshot of a ViewControllerModellableView with a specific ViewModel.

import TempuraTesting

class UITests: XCTestCase, ViewTestCase {

func testAddItemScreen() {
self.uiTest(testCases: [
“addItem01”: AddItemViewModel(editingText: “this is a test”)
])
}
}

The identifier will define the name of the snapshot image in the file system.

You can also personalize how the view is rendered (for instance you can embed the view in an instance of UITabBar) using the context parameter. Here is an example that embeds the view into a tabbar:

import TempuraTesting

class UITests: XCTestCase, ViewTestCase {

func testAddItemScreen() {
var context = UITests.Context<AddItemView>()
context.container = .tabBarController

self.uiTest(testCases: [
“addItem01”: AddItemViewModel(editingText: “this is a test”)
], context: context)
}
}

If some important content inside a UIScrollView is not fully visible, you can leverage the scrollViewsToTest(in view: V, identifier: String) method. This will produce an additional snapshot rendering the full content of each returned UIScrollView instance.

In this example we use scrollViewsToTest(in view: V, identifier: String) to take an extended snapshot of the mood picker at the bottom of the screen.

func scrollViewsToTest(in view: V, identifier: String) -> [String: UIScrollView] {
return [“mood_collection_view”: view.moodCollectionView]
}

YOU MIGHT ALSO LIKE...
STULabel features

STULabel is an open source iOS framework for Swift and Objective-C that provides a label view (STULabel), a label layer ...

Reactant

Reactant is a foundation for rapid and safe iOS development. It allows you to cut down your development costs by ...

iOS Viper Architecture: Sample App

This repository contains a detailed sample app that implements VIPER architecture using libraries and frameworks like Alamofire, AlamofireImage, PKHUD,