As part of exploring SwiftUI, I discovered that while I enjoyed the native state management system, it lacked a few qualities, namely scalability and traceability. Given the similarity between state management in SwiftUI and React, which I explored in my previous blog post, I looked into creating a Redux package for SwiftUI. This is available on GitHub, and can be imported into Xcode projects using Swift Package Manager.
State management using SwiftUI
State is managed in SwiftUI using property wrappers and a native module called Combine. The three ways of declaring state are by declaring @State
, @ObservedObject
and @EnvironmentObject
wrapped properties on View
objects. State can also be passed to child views as @Binding
properties. See my previous blog post for more on how this works.
This native SwiftUI state management allows two way databinding, which is performed using the Binding
struct. This can lead to children directly mutating the state of their parents, and state updates that are completely untraced. The @State
wrapper works best for basic state such as String
or Int
values stored directly on a View
. For more complicated state, the @ObservedObject
wrapper can be applied to a property of ObservableObject
type. However, these properties are still stored directly on the View
. This can be improved by using the @EnvironmentObject
wrapper, which allows you to inject an ObservableObject
into a parent view, and access it directly from any children, keeping a central location for the state of a view hierarchy. However, multiple environment objects may still be in use within the application, and may be mixed with state stored on views. Overall, it felt like a little bit of work needed doing to make state changes easy to manage for large scale applications. Redux is a pattern which performs this role with React, so I looked into adding it into SwiftUI.
State management using Redux
Redux is summed up by its three principles:
- The state of your whole application is stored in an object tree within a single store.
- The only way to change the state is to emit an action, an object describing what happened.
- To specify how the state tree is transformed by actions, you write pure reducers.
The first principle makes Redux a scalable state management system by providing a single source of truth, even for large applications. It also makes it easy to inject state, or start the application in a given state because you only need to instantiate the store in the desired state. The second principle keeps state changes traceable, allowing easy debugging and logging. The third principle makes state changes replayable, by ensuring that the current state is known in its entirety if its initial state is known, and all the actions sent are known. This makes state changes simple while opening the door for very useful tools such as ‘time-travel’, which allows you to easily skip betwen states by reducing the set of actions required to get to them.
Basic Swift Redux protocols
There are four basic components to the Redux system of state management - a Store, the State which it contains, the Actions, and the Reducers. Following the principle of Protocol Oriented Programming, the first step was to create protocols, if needed, to represent these four components.
ReduxRootState
The state could be any object which is entirely implementation dependent, and so the ReduxRootState
protocol doesn’t have much to it. However, to integrate properly with SwiftUI’s state management, it is class constrained (conformed to AnyObject
). In order for reducer funtions to be pure, which the third principle of redux requires, the state should move memory location if it is changed. Therefore a deepcopy
function is imposed in the ReduxRootState
protocol to encourage this.
public protocol ReduxRootState: AnyObject {
func deepcopy() -> Self
}
ReduxRootReducer
public protocol ReduxRootReducer {
associatedtype State
static func reduce(_ action: ReduxAction, state: State) -> State
}
The ReduxRootReducer
protocol has one requirement, which is a function which takes in an action and the previous state, and returns the new state. I made this function static because this must be a pure function according to the 3rd principle of Redux, so it doesn’t need to belong to an object because its not altered by its surrounding state. The protocol is generic, with an associatedtype State
because as was just stated, the State could be any object and is implementation dependent. The protocol is called ReduxRootReducer
because sub-reducers can easily be split out and used within the reduce
function, but we need a single entry point for all actions.
ReduxAction
public protocol ReduxAction { }
All actions will need to be dispatched to the same reducer which will then check their type in the reduce
function to create the new state. This protocol allows polymorphism among actions without enforcing any requirements (as they could contain anything).
ReduxStore
public protocol ReduxStore: ObservableObject where ObjectWillChangePublisher == ObservableObjectPublisher, Reducer.State == State {
associatedtype State
associatedtype Reducer: ReduxRootReducer
var state: State { get set }
}
extension ReduxStore {
public func dispatch(_ action: ReduxAction) {
// Inform SwiftUI that the state will change
objectWillChange.send()
state = Reducer.reduce(action, state: state)
}
}
The ReduxStore
protocol declaration has only one requirement, which is a state
property. The protocol is generic, with an associatedtype State
because the State will be implementation dependent. We also have an associatedtype Reducer
which conforms to the ReduxRootReducer
we defined earlier. We constrain in the generic where
clause that the State
type of the ReduxStore
must be the same as the State
type of the Reducer
. In the protocol extension, we provide the method to dispatch actions to the store. This takes in an action, which it passes to the implementation of the reducer, and sets the state
property with the result.
In addition, the ReduxStore
protocol itself conforms to a protocol from the Combine framework - ObservableObject
. This is a generic protocol as well, so we provide a concrete type ObjectWillChangePublisher
for its associatedtype ObservableObjectPublisher
. Conforming to ObservableObject
is necessary for injecting the ReduxStore
instance into SwiftUI. In addition, at the end of dispatch
the objectWillChange
notification is sent to trigger an update in SwiftUI.
Injecting the store using EnvironmentObject
Any View
object which displays a piece of state from the store needs access to that piece of state. That can either be done by injecting the state directly into the View
or by injecting the redux store directly in. In this implementation of Redux, the View
needs access to a ReduxStore
in order to dispatch actions, so it seems to make most sense to inject the store directly into a view. In addition, this allows us to instruct SwiftUI to only watch the store object, and re-render when it changes, rather than watching lots of different pieces of state for different views.
Using the EnvironmentObject
system makes sense as a way to inject the store into a view hierarchy. An EnvironmentObject
can be accessed by any child view without it being passed to it by its parent. This provides a similar functionality to the connect
function in React-Redux. We therefore only have to inject a ReduxStore
instance into the root view of a hierarchy and we have a single source of truth for that hierarchy. For a concrete implementation of ReduxStore
called RootStore, this can be done like this:
let store = RootStore()
let rootView = RootView()
.environmentObject(store)
For some view that is a descendant of rootView in the view hierarchy, the store can be pulled in and used like this:
struct ChildView: View {
@EnvironmentObject var store: RootStore
var body: some View {
Button(action: {
self.store.dispatch(SomeAction())
}
label: {
Text(store.state.message)
})
}
}
The @EnvironmentObject
wrapper has the double effect of informing SwiftUI to inject the RootStore
into this view, and also to watch the store for the objectWillChange
notification, and recompute body
when the store changes.
Integrating data binding
For views that perform state changes in an imperative fashion, such as Button
above, it is easy to dispatch actions to the store in their callbacks. The big challenge to integrating Redux with SwiftUI comes in embracing SwiftUI’s two way databinding. Many views (such as TextField
or Toggle
) take in a Binding
object which wraps an intrinsically linked piece of state (a String
for a TextField
or a Bool
for a Toggle
). The view is updated if the state is updated, and the state is updated if the view is updated. This breaks the second principle of Redux, that state updates must only be performed by sending actions.
ReduxBindable property wrapper
To overcome this challenge, I used a new feature in Swift 5 called property wrappers. SwiftUI includes a few of these (@State
, @Binding
, @EnvironmentObject
) which I’ve mentioned already. A property wrapper is an object which wraps a value. It is declared using the @propertyWrapper
keyword, and can then be applied to a property on an object. I created a property wrapper called ReduxBindable
:
@propertyWrapper
public final class ReduxBindable<Store: ReduxStore, State, Action: BindingUpdateAction> where Action.State == State,
Store.Reducer.State == Store.State, Store.State.Store == Store {
private var state: State
public var store: Store?
public var wrappedValue: State {
get {
return state
}
set {
let action = Action.init(state: newValue)
store?.dispatch(action)
}
}
public init(wrappedValue: State) {
state = wrappedValue
}
public var projectedValue: State {
get {
return state
}
set {
state = newValue
}
}
}
ReduxBindable
has three generic types. The Store
generic type allows it to be used with specific implementations of ReduxStore
. The State
generic type allows the same property wrapper to be used for any form of state, from String
to a custom type. The Action
generic type enforces a single action to update the state within any ReduxBindable
object.
ReduxBindable
has a wrappedValue
property, which is used in a SwiftUI View
for databinding. ReduxBindable
intercepts a setting of this value and dispatches an action to the store. This action is of type BindingUpdateAction
, which is a protocol which enforces a conforming object to carry a value for the State
type that ReduxBindable
wraps.
public protocol BindingUpdateAction {
associatedtype State
init(state: State)
var state: State { get }
}
ReduxBindable
also has a projectedValue
property which is used in the reducer to set the new value of the state. Using ReduxBindable
as a property wrapper with these two properties allows the View
to perform normal databinding using vanilla SwiftUI syntax, while obeying the second and third principles of Redux that state updates can only be performed by actions through pure reducer functions.
Unfortunately, if ReduxBindable
is used as a property wrapper, the class cannot be initialized with the reference to the Redux store, which it needs to dispatch the action. Therefore while complexity is hidden from the View
structs by using a property wrapper, because they don’t know that the property is wrapped, complexity is introduced into our Redux system. We can add an initialize
function to the ReduxStore
protocol, and update a ReduxRootState
protocol with an initialize
function to allow the store to be injected into ReduxBindable
properties after they are created.
final class ExampleState: ReduxRootState {
@ReduxBindable<RootStore, String, UpdateMessageBindableAction> var message: String = ""
func initialize(store: RootStore) {
// Underscore syntax accesses the ReduxBindable instance instead of the message state (i.e. wrappedValue property)
_message.store = store
}
func deepcopy() -> ExampleState {
let newState = ExampleState()
newState._message = _message
return newState
}
}
In addition, ReduxRootReducer
now has two overloads of dispatch
, one of which is generic and takes in a BindingUpdateAction
. The final package is available in a public GitHub repository and can be imported into SwiftUI projects using Swift Package Manager.
Redux middleware and store enhancers
A big part of what makes using Redux so appealing is middleware. Middleware sits between an action dispatch and a state update, and allows you to intercept state updates and perform them asynchronously, or to chain new dispatches. My SwiftUIBindableRedux package provides an applyMiddleware
function, and a Middleware
protocol to allow you to write and add your own middleware. It works in much the same way as the JavaScript Redux middleware, so I won’t go into detail here on the implementation. The example project I’ve written demonstrates how to use middleware in making simple logging middleware and applying it to the store. A very common form of middleware used withRedux is Redux-Thunk. Thunks allow you to dispatch functions to the store in the place of an action. The only middleware implementation that comes with the BindableSwiftUIRedux package is thunk middleware, because it’s so common and so useful. A simple example of middleware is a basic logger, which prints the store before and after every action dispatch:
enum LoggingMiddleware: Middleware {
typealias Store = RootStore
static var middleware: Store.Middleware {
return { (dispatch: Dispatch, getState: @escaping () -> CounterState) in
return { (next: @escaping Dispatch) in
return { (action: ReduxAction) in
print("Logging state")
print(getState())
next(action)
print(getState())
print("End logging state")
}
}
}
}
}
As in the JavaScript Redux library, the applyMiddleware
function is implemented as a StoreEnhancer
which is added to the createStore
function. The advantage of this approach is that it allows users to develop custom StoreEnhancers
, which can be composed and used in this function alongside or instead of applyMiddleware
.
Pros and cons of the package
The package achieves what I set out to do, which was to create a Redux framework for SwiftUI. The package embraces SwiftUI’s idioms by using EnvironmentObject
and permitting databinding using the ReduxBindable
wrapper. It satisfies the first principle of Redux by providing a singular store which is the single source of truth of the application. It satisfies the third principle of Redux by changing state through passing actions through pure reducers.
However, the package doesn’t enforce the second principle of Redux, which is that the only way to change state is by emitting actions. The reason for this is that when the store is injected into a View
it can’t be declared as readonly
. In addition, the state
property on the store can’t be declared as private
because it satisfies the requirement of the ReduxStore
protocol. This doesn’t stop the package being a Redux framework, but it does rely on implementations to respect the second principle, where it would be nice if it was enforcedby the package itself.
Counter app
What Redux article would be complete without a simple counter app written to demonstrate its use? The below example is a simple counter app, and is condesnsed from one of my example projects for the package. It has a Text
view to display the count, and a Button
to update the count. It has two modes, controlled by a Toggle
- incrementing the counter and decrementing the counter. A user can optionally add a limit to the count by typing in a numeric text field. The limit is enforced by dispatching a Thunk
which checks the value against the limit before dispatching an Increment
or Decrement
action. This example demonstrates the usefulness of Redux, and the advanced capability provided by the way that my package integrates with SwiftUI databinding. Enjoy!
Redux Objects
final class CounterStore: ReduxStore {
typealias Reducer = CounterReducer
lazy var storedDispatch: Dispatch = defaultDispatch
var state: CounterState
var objectWillChange = ObservableObjectPublisher()
init(state: CounterState?) {
self.state = state ?? CounterState()
}
}
final class CounterState: ReduxRootState {
typealias Store = CounterStore
var count = 0
@ReduxBindable<CounterStore, Bool, UpdateBindingToggle> var isIncrementing = true
@ReduxBindable<CounterStore, String, UpdateLimitText> var limitText = ""
func deepcopy() -> CounterState {
let newState = CounterState()
newState.count = self.count
newState._isIncrementing = _isIncrementing
newState._limitText = _limitText
return newState
}
func initialize(store: CounterStore) {
_isIncrementing.store = store
_limitText.store = store
}
}
enum CounterReducer: ReduxRootReducer {
static func reduce(_ action: ReduxAction, state: CounterState) -> CounterState {
let newState = state.deepcopy()
switch action {
case let action as UpdateBindingToggle:
newState.$isIncrementing = action.state
newState.$limitText = ""
case let action as UpdateLimitText:
newState.$limitText = action.state
case is IncrementCount:
newState.count += 1
case is DecrementCount:
newState.count -= 1
default:
break
}
return newState
}
}
struct UpdateBindingToggle: BindingUpdateAction {
let state: Bool
}
struct UpdateLimitText: BindingUpdateAction {
let state: String
}
struct IncrementCount: ReduxAction { }
struct DecrementCount: ReduxAction { }
Counter View
struct Counter: View {
@EnvironmentObject var store: CounterStore
var toggleText: String {
return store.state.isIncrementing ? "Increase" : "Decrease"
}
var updateCounter: Thunk<CounterStore> {
Thunk<CounterStore> { dispatch, getState in
let state = getState()
var shouldChange = true
if let numberLimit = Int(state.limitText) {
shouldChange = state.isIncrementing ? state.count < numberLimit : state.count > numberLimit
}
if shouldChange {
let action: ReduxAction = state.isIncrementing ? IncrementCount() : DecrementCount()
dispatch(action)
}
}
}
var body: some View {
VStack {
Text("\(store.state.count)")
HStack {
Button(action: updateCount) {
Text("Update counter")
}
Spacer()
Toggle(self.toggleText, isOn: $store.state.isIncrementing)
.frame(width: 150, height: 50, alignment: .trailing)
}
TextField("Enter number limit", text: $store.state.limitText)
.keyboardType(.numberPad)
}
.padding()
}
func updateCount() {
store.dispatch(updateCounter)
}
}
SceneDelegate (app initialization)
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Create the store with logging and thunk middleware.
let rootStore = CounterStore.createStore(
reducer: CounterReducer.self,
preloadedState: nil,
enhancer: CounterStore.applyMiddleware(middlewares: [
LoggingMiddleware.middleware,
ThunkMiddleware<CounterStore>.middleware
])
)
.initialize()
// Create the SwiftUI view and inject the store as an environment object
let contentView = Counter()
.environment(\.managedObjectContext, context)
.environmentObject(rootStore)
// Use a UIHostingController as window root view controller.
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
}