A Signal is a relatively recent and powerful tool for managing state and changes over time in frontend development. Originally introduced to a broad audience by SolidJS in the web community, the Angular Team put traction on Signals in 2023. Web developers searched for quite a long time for a scalable solution to manage state changes that are easy to learn yet powerful enough to allow for fine-grained UI updates to help developers avoid excessive DOM regeneration.

In Swift, Apple introduced concepts such as State and State bindings and View identity to address this problem. However, Signals might be a superior solution since they are flexible enough for any state management, not only UI state. In addition, SwiftUI has tons of implicit assumptions and constraints that are hard to learn and restrict the architectural decisions of an app.

As a developer with 10+ years of experience working on web and mobile development, I see patterns emerging from one platform to another, pushing the whole development ecosystem further. Why shouldn't we actively participate in this everlasting search for the best solutions and practices?

Since some of my colleagues found Signals to be "magic," I jumped into one of my favorite languages and see if I could demystify Signals for them.

Motivation

Signals in Swift allow to maintain computed state inside view models for better reusability. This is helpful especially in complex applications where derived states are shared among many views, such as a account balance or transaction history.

Rather then copy over variables Signals can provide an alternative to share state throughout Swift applications.

class ViewModel: SignalModel {
    
    public var username = MutableSignal("")
    public var password = MutableSignal("")
    
    public var isLoginButtonEnabled: Signal<Bool>!
    
    override init() {
        super.init()
        self.isLoginButtonEnabled = computed(fn: { ctx in
            self.username.fn(ctx) != "" && self.password.fn(ctx) != ""
        })
    }
}

Signals

So what are Signals? The best way to imagine Signals is to think of them as a mailbox.

A mailbox contains messages to be looked at. If the content of the mailbox changes, the mailbox will raise its flag, "signaling" to its observers there is new information to be processed.

Signals are values in a box. The box notifies when it has new contents that need to be processed.

Signals are powerful when combined with closures that run every time a signal value changed like effect and computed in the following example:

let a = MutableSignal(3) // Mailbox of a
let b = MutableSignal(6) // Mailbox of b

// Observer, recalculates any time a or b changes
let c = computed { ctx in a.fn(ctx) + b.fn(ctx) } 

// Observer, executes any time a or b changes since c is a result of a+b
effect { ctx in print("c=\(c.fn(ctx))") } // Prints c=9

b.mutate(10) // Signals computed ^^ and effect ^^ to process again 
// Automatic output c=13

In contrast to other reactive solutions, the great benefit of Signals is that they are very close to the imperative coding style and avoid rethinking coding patterns.

let a = 3                   let a = MutableSignal(3)
let b = 6                   let b = MutableSignal(6)
let c = a + b               let c = computed { ctx in a.fn(ctx) + b.fn(ctx) }

print("c=\(c)")             effect { ctx in print("c=\(c.fn(ctx))") }

a=1                         // Automatically recomputes c and runs effect again
c = a + b                   a.mutate(1)

print("c=\(c)")

Signals ♥ Swift

Once we have clarified what Signals are, let us dive deeper into how a Signal can be implemented.

One consideration in Swift when building new classes is whether to implement them as a structure or class. The difference is that structs are treated as value types while classes act as reference types.

In our case, we want to use Signals later as models for our SwiftUI views and utilize them for change detection. To achieve this, Signals must be attributed as @ObservableObject and, therefore, of reference type. In addition, reference type allows us to mutate and change the value based on a SwiftUI view directly.

Let us start with creating a class that stores a value and limits access to a function that ensures that the subscriber is registered as an observer.

public class Signal<T> {
  public var value: T // The value that we keep track of
  // Observers that we want to notify about changes
  public var observers: [AnyObject] = []
    
  public init(_ value: T) {
      self.value = value
  }

  // The function that provides access to the value and ensures that the accessor
  // is registered as observer
  public func fn(_ ctx: AnyObject) -> T {
        // If the context is already added, do not add again.
        if !self.observers.contains(where: { iteratedCtx in
            return iteratedCtx === ctx
        }) {
            self.observers.append(ctx)
        }
        return self.value
    }
}

The code is quite simple. A signal can be created, and its value can be read via the fn function.

class FirstSignalApp {
    let firstSignal = Signal(1)
    
    init() {
        print(firstSignal.fn(self))
    }
}

FirstSignalApp() // Prints 1

Mutation and signaling

So far, this was easy. However, now comes the essential part of Signals. Being able to change values over time and notify their observers.

Let us take a look at the observers of the Signal object:

public class Signal<T> { 
...
  public var observers: [AnyObject] = []
...
}

Notifiable protocol

The observer's array is of type AnyObject, but we want the Signal to notify the observers when their value changes. So, we define a protocol that provides an interface to do this and use it instead of AnyObject.

We call the protocol for our observers by purpose notifiable to indicate we are utilizing the pull pattern for our observable implementation.

public protocol Notifiable {
    func notify()
}

public class Signal<T> { 
  ...
  public var observers: [Notifiable] = []

  public func fn(_ ctx: Notifiable) -> T { ... }
  ...
}

Now we are getting errors in our get value fn() function of the Signal. Why this?

None

Reference vs. Value type

This is because the Notifiable protocol does not support an identity check. In Swift, protocols can be implemented by either structs or classes. Since structs are value-typed, they do not come with an identity automatically. When reviewing what a value type is, this makes sense. Structs are stored in Stack Memory as properties. So, each instance of a struct gets its own memory on the stack. So, what would identity mean in this context? Is a struct identical if all of its values are equal? Is a struct identical if all of its values are identical? Or is it identical if it's really the exact location in memory? Swift does not answer this or support identical or equality on complex value types such as structs.

If you want to become more familiar with Swift Value and Reference Types, you can find more at https://developer.apple.com/swift/blog/?id=10.

Mutating a signal

In our case, our observers will act as shared and mutable states since they will either be Models for SwiftUI or Signals themselves. So we can restrict our Notifiable protocol to reference types.

public protocol Notifiable: AnyObject {
  func notify()
}

Extending from AnyObject restricts our Signal Notifiable protocol to reference types. Once our observer's array items of the Signal provide a notification method, an implementation for changing the value can be implemented.

public class MutableSignal<T>: Signal<T> {
    public func mutate(_ value: T) {
        self.value = value
        for ctx in self.observers {
            ctx.notify()
        }
    }
}

We decided for a Signal<T> subclass to limit the mutation functionality only to MutableSignals. This is important since we later implement Computed Signals, which under no circumstances should be mutated by an API since they derive their state only from other Signals.

Extensions with generic types of restrictions

One crucial aspect of Signals is they are only notifying their observers if their values actually changed to avoid unnecessary recalculation.

One powerful feature of Swift to achieve this is specialized extensions.

Extensions in Swift allow the addition of functions to existing classes. They can be restricted with type arguments. To skip the notification on observers if the value didn't change, an extension to MutableSignal can be limited to Signals containing Equatable types as values.

public extension MutableSignal where T: Equatable {
    func mutate(_ value: T) {
        if (self.value == value) {
            return
        }
        self.value = value
        for ctx in self.observers {
            ctx.notify()
        }
    }
}

Let's test the implementation so far:

class FirstSignalApp: Notifiable {
    let firstSignal = MutableSignal(1)

    
    init() {
        print(firstSignal.fn(self))
        firstSignal.mutate(5)
        firstSignal.mutate(5)
    }
    
    func notify() {
        print("changed")
    }
}
FirstSignalApp()
// Output 1
// Output changed

Perfect, the output only prints changed once, so our extension worked.

Computed properties

Now, once we have the signaling in place, we can start implementing the power features of Signals. Computed property and effects.

A computed property is a read-only signal that should recalculate every time its child Signals (the values it derives its result from) change.

To achieve this, we want to provide a closure-based API:

let a = MutableSignal<Int>(3)
let b = MutableSignal<Int>(5)
let c = computed { ctx in a.fn(ctx) + b.fn(ctx) }

a.mutate(15) // c is automatically updated to 20
b.mutate(0)  // c is automatically updated to 15

The idea is simple. The provided closure will be stored to be called whenever one of its child signals changes its value. The Notifiable protocol will be used to notify when the closure needs to be executed.

internal class ComputedObserver<T>: Notifiable where T: Equatable {
    internal var signal: Signal<T>!
    
    internal var fn: (_ ctx: any Notifiable) -> T
    
    init(fn: @escaping (_: any Notifiable) -> T) {
        self.fn = fn
        self.signal = Signal<T>(self.fn(self))
    }
    
    internal func notify()  {
        // Reruns the closure with the latest updated signal values
        let newValue = self.fn(self)
        self.signal.update(newValue)
    }
    
}

As the code shows, the internal var fn: (_ ctx: any Notifiable) -> T property stores the calculation closure provided by the developer using the computed signal. Our new class ComputedObserver conforms to the notifiable protocol of the Signal and will invoke the fn: (_ ctx: any Notifiable) -> T property every time when notify() is called.

In addition, we also need to be able to update the value of Signal<T> since we want to update the value internally of a computed property, once their observed Signals change:

public class Signal<T> {
  ...
  internal func update(_ value: T) {
      self.value = value
        
      for ctx in self.observers {
          ctx.notify()
      }
  }
...
}

extension Signal where T: Equatable {
    func update(_ value: T) {
      if (value == self.value) {
        return 
      }
      self.value = value
        
      for ctx in self.observers {
          ctx.notify()
      }
    }
}

As a last step, we provide a global function to make working with the computed property handy:

public func computed<T>(fn: @escaping (_ ctx: Notifiable) -> T) -> Signal<T> where T: Equatable {
    let ctx = ComputedObserver<T>(fn: fn)
    return ctx.signal
}

Now "c" can be defined as a computed signal. b can be mutated, and c automatically adapts.

let a = MutableSignal(1)
let b = MutableSignal(3)
let c = computed { ctx in
    a.fn(ctx) + b.fn(ctx)
}

print(c.value) // Outputs 4
b.mutate(15)
print(c.value) // Outputs 16

Effects

Effects are similar to computed properties, except they do not produce new values for Signals. Instead their purpose is to run code, whether a value changes.

Effects allow Signals to connect to applications data flow, for example, UIKit applications that want to update their UIViews based on properties of their classes. They can implement the effect and update their view state once relevant Signals change.

Implementing the EffectObserver is similar to the ComputedObserver.

public class EffectObserver : Notifiable  {
    internal var signal: Signal<Void>!
    
    internal var fn: (_ ctx: EffectObserver) -> Void
    
    init(fn: @escaping (_: EffectObserver) -> Void) {
        self.fn = fn
        self.signal = Signal<Void>(fn(self))
    }
    
    public func notify() {
       self.signal?.value = self.fn(self)
    }
}

public func effect(fn: @escaping (_ ctx: EffectObserver) -> Void) -> Void {
    _ = EffectObserver(fn: fn)
}

Now, the effect can be used to invoke code any time a variable changes, and the print does not need to be manually invoked every time a mutate changes a signal value.

let a = MutableSignal(1)
let b = MutableSignal(3)
let c = computed { ctx in
    a.fn(ctx) + b.fn(ctx)
}

effect { ctx in
    print("Effect \(c.fn(ctx))")
} // Prints 4

b.mutate(15) // Prints 16

SwiftUI support

To add support for SwiftUI, Signal<T> can inherit from ObservableObject and provide its value property with the @Published property wrapper like this:

public class Signal<T>: ObservableObject ... {
... 
    @Published public var value: T 
...
}

Most likely Signals shall be used in view models. ViewModels in SwiftUI must inherit ObservableObject so Views can subscribe to their changes.

Since our changes are triggered by the Signals we created, we reflect the SignalModel and forward all changes via the effect closure.

protocol SignalObserver {
    associatedtype T
    func fn(_ ctx: Notifiable) -> T
}

extension Signal: SignalObserver {
    
}

open class SignalModel: ObservableObject {
   public init() {
        let mirror = Mirror(reflecting: self)
        mirror.children.forEach { child in
            // Only for Signal properties, rest ignore
            if let child = child.value as? (any SignalObserver) {
                effect { ctx in // Observes changes of all signals
                    _ = child.fn(ctx)
                    self.objectWillChange.send()
                }
            }
        }
    }
}

SignalModel forwards all Swift Combine notifications from the Signals and forwards them to the Signal model.

How to use?

This now allows to compute complex state in ViewModel classes for Swift UI like this. The following example shows an example that sets a display property based on wheter or not a name has been empty.

class ViewModel: SignalModel {
    public var display: Signal<Bool>!
    
    public var name = MutableSignal("")
    
    override init() {
        super.init()
        self.display = computed(fn: { ctx in
            self.name.fn(ctx) != ""
        })
    }
}

The following hello world view shows only Hello World if the name is being set:

struct HelloWorld: View {
    
    @StateObject public var viewModel = ViewModel()
    
    var body: some View {
        uiEffect { ctx in
            VStack {
                if self.viewModel.display.fn(ctx) {
                    Text("Hello \(self.viewModel.name.fn(ctx))")
                }
                Button("Set Name") {
                    self.viewModel.name.mutate("World")
                }
            }
            .frame(width: 200)
            .frame(height: 200)
        }
    }
}

Summary

Signals are far from magic. They combine the intelligent use of closures and observer patterns to model data flow.

To sum it up, the TLTR: Signals for UIKit, CoreGraphics, etc can be very useful to manage detect changes and re-render if needed.

For SwiftUI Signals could helpful to combine data flow with model changes, if SwiftUI would not be coupled so tightly to its own change detection based on View identity and propertyWrappers.

In any case Signals as an alternative to Swift Combine or Notification Center with an API, that allows imperative style code to be transferred to reactive code easily.

There is more to signals such as atomic updates that go beyond the scope of what we can cover in this article. Check out the documentation and the SwiftSignal repository https://github.com/mkloeppner/SwiftSignals and feel free to give me feedback at Twitter: https://twitter.com/mkloeppner

Demo — Swift Playground

import SwiftUI
import PlaygroundSupport

public protocol Notifiable: AnyObject {
    func notify()
}

@available(iOS 15.0, *)
public class Signal<T>: ObservableObject {
    @Published public var value: T // The value that we keep track of
    // Observers that we want to notify about changes
    public var observers: [Notifiable] = []
    
    public init(_ value: T) {
        self.value = value
    }
    
    internal func update(_ value: T) {
        self.value = value
        
        for ctx in self.observers {
            ctx.notify()
        }
    }
    
    public func fn(_ ctx: Notifiable) -> T {
        // If the context is already added, do not add again.
        if !self.observers.contains(where: { iteratedCtx in
            return iteratedCtx === ctx
        }) {
            self.observers.append(ctx)
        }
        return self.value
    }
}

@available(iOS 15.0, *)
public class MutableSignal<T>: Signal<T> {
    public func mutate(_ value: T) {
        self.value = value
        for ctx in self.observers {
            ctx.notify()
        }
    }
}

@available(iOS 15.0, *)
public extension MutableSignal where T: Equatable {
    func mutate(_ value: T) {
        if (self.value == value) {
            return
        }
        self.value = value
        for ctx in self.observers {
            ctx.notify()
        }
    }
}

@available(iOS 15.0, *)
internal class ComputedObserver<T>: Notifiable where T: Equatable {
    /**
     * A signal created for the computed closure to notify observers of the computed property
     */
    internal var signal: Signal<T>!
    
    /**
     * The closure that needs to be run every time a signal of the closure changed
     */
    internal var fn: (_ ctx: any Notifiable) -> T
    
    init(fn: @escaping (_: any Notifiable) -> T) {
        self.fn = fn
        self.signal = Signal<T>(self.fn(self))
    }
    
    /**
     * Recalculates the value of the computed signal upon notification and notifies parent contexts to recalculate too
     */
    internal func notify()  {
        // Reruns the closure with the latest updated signal values
        let newValue = self.fn(self)
        self.signal.update(newValue);
    }
    
}

@available(iOS 15.0, *)
extension Signal where T: Equatable {
    func update(_ value: T) {
        if (value == self.value) {
            return
        }
        self.value = value
        
        for ctx in self.observers {
            ctx.notify()
        }
    }
}

@available(iOS 15.0, *)
public func computed<T>(fn: @escaping (_ ctx: Notifiable) -> T) -> Signal<T> where T: Equatable {
    let ctx = ComputedObserver<T>(fn: fn)
    return ctx.signal
}

@available(iOS 15.0, *)
public protocol Notified: AnyObject {
    var isUpdating: Bool { get set }
}

@available(iOS 15.0, *)
public class EffectObserver : Notifiable  {
    internal var signal: Signal<Void>!
    
    internal var fn: (_ ctx: EffectObserver) -> Void
    
    init(fn: @escaping (_: EffectObserver) -> Void) {
        self.fn = fn
        self.signal = Signal<Void>(fn(self))
    }
    
    public func notify() {
        self.signal?.value = self.fn(self)
    }
}

@available(iOS 15.0, *)
public func effect(fn: @escaping (_ ctx: EffectObserver) -> Void) -> Void {
    _ = EffectObserver(fn: fn)
}

@available(iOS 15.0, *)
protocol ViewObserver {
    associatedtype V: View
    
    var signal: Signal<V>! { get set }
}

@available(iOS 15.0, *)
internal class SwiftUIEffectObserver<T> : Notifiable, ViewObserver where T: View {
    typealias V = T
    
    internal var signal: Signal<T>!
    
    internal var fn: (_ ctx: Notifiable) -> T
    
    init(fn: @escaping (_: Notifiable) -> T) {
        self.fn = fn
        self.signal = Signal<T>(fn(self))
    }
    
    func notify() {
        self.signal?.value = self.fn(self)
    }
    
}

/**
 * Creates an effect that triggers every time a signal within the closure changes
 */
@available(iOS 15.0, *)
@ViewBuilder public func uiEffect(@ViewBuilder fn: @escaping (Notifiable) -> some View) -> some View  {
    let context = SwiftUIEffectObserver { ctx in
        fn(ctx).onAppear(perform: {
            print("Update ui")
        })
    }
    context.signal.fn(context)
}

protocol SignalObserver {
    associatedtype T
    func fn(_ ctx: Notifiable) -> T
}

@available(iOS 15.0, *)
extension Signal: SignalObserver {
    
}

@available(iOS 15.0, *)
open class SignalModel: ObservableObject {
    public init() {
        let mirror = Mirror(reflecting: self)
        mirror.children.forEach { child in
            if let child = child.value as? (any SignalObserver) {
                effect { ctx in
                    _ = child.fn(ctx)
                    self.objectWillChange.send()
                }
            }
        }
    }
}

@available(iOS 15.0, *)
class ViewModel: SignalModel {
    public var display: Signal<Bool>!
    
    public var name = MutableSignal("")
    
    override init() {
        super.init()
        self.display = computed(fn: { ctx in
            self.name.fn(ctx) != ""
        })
    }
}

@available(iOS 15.0, *)
struct ContentView: View {
    
    @StateObject public var viewModel = ViewModel()
    
    var body: some View {
        uiEffect { ctx in
            VStack {
                if self.viewModel.display.fn(ctx) {
                    Text("Hello \(self.viewModel.name.fn(ctx))")
                }
                Button("Action") {
                    self.viewModel.name.mutate("Manfred")
                }
            }
            .frame(width: 200)
            .frame(height: 200)
        }
    }
}

let view = ContentView()
PlaygroundPage.current.setLiveView(view)