How to Use NotificationCenter for Inter-Component Communication in Swift

Use NotificationCenter to broadcast events between decoupled components by posting named notifications in senders like AnthropicClient and observing them via either selector-based or block-based APIs in receivers like AgentService and TimelineContainerView, always cleaning up observers in deinit to prevent memory leaks.

NotificationCenter provides the built-in publish/subscribe mechanism in Apple’s frameworks, allowing any part of an app to broadcast events without direct references to listeners. The palmier-io/palmier-pro repository demonstrates three production patterns for inter-component communication: selector-based observation for AppKit integration, block-based observation with tokens for Swift-native services, and custom notification posting for keychain updates.

NotificationCenter Patterns in Palmier Pro

The Palmier Pro codebase implements distinct observation strategies depending on the component architecture. Understanding these patterns helps you choose the right approach for your own Swift projects.

Selector-Based Observation for AppKit Integration

In Sources/PalmierPro/Timeline/TimelineContainerView.swift (lines 43-53), the codebase uses the classic selector-based API to observe scroll view changes. This pattern works best when coordinating with NSObject subclasses that require @objc methods.

scrollView.contentView.postsBoundsChangedNotifications = true
NotificationCenter.default.addObserver(
    context.coordinator,
    selector: #selector(Coordinator.scrollViewBoundsChanged),
    name: NSView.boundsDidChangeNotification,
    object: scrollView.contentView
)

The Coordinator class implements scrollViewBoundsChanged as an @objc method marked with @MainActor to ensure UI updates occur on the main thread. This approach requires manual cleanup in the deinit method:

deinit {
    NotificationCenter.default.removeObserver(self)
}

Block-Based Observation with Tokens

For pure Swift components like AgentService in Sources/PalmierPro/Agent/AgentService.swift (lines 13-21), the repository uses the modern block-based API that returns an opaque token. This pattern captures a closure and allows explicit queue specification.

apiKeyObserver = NotificationCenter.default.addObserver(
    forName: .anthropicAPIKeyChanged,
    object: nil,
    queue: .main
) { [weak self] _ in
    MainActor.assumeIsolated {
        self?.reloadAPIKey()
    }
}

The apiKeyObserver property stores the returned NSObjectProtocol token, enabling precise removal without deregistering all observers for that object. The [weak self] capture prevents retain cycles, while MainActor.assumeIsolated ensures thread safety when updating UI-dependent state.

Posting Custom Notifications

When the Anthropic API key changes, Sources/PalmierPro/Agent/Clients/AnthropicClient.swift (lines 11-13, 27-28) broadcasts the event using a custom notification name defined in an extension.

static func save(_ key: String) {
    KeychainStore.save(key, account: account)
    NotificationCenter.default.post(name: .anthropicAPIKeyChanged, object: nil)
}

This notification carries no userInfo payload; observers simply reload the key from the keychain when received. The pattern decouples the keychain storage logic from the service layer that consumes the API key.

Implementing Selector-Based Observation

Use the selector API when integrating with AppKit components or when the observer inherits from NSObject and requires Objective-C runtime compatibility.

  1. Enable notification posting on the source object if required (e.g., postsBoundsChangedNotifications = true).
  2. Call addObserver(_:selector:name:object:) with a reference to the observing object and a selector method.
  3. Implement the selector method with the @objc attribute and @MainActor for UI updates.
  4. Remove the observer in deinit using removeObserver(_:) with the same object reference.

The TimelineContainerView also observes NSView.frameDidChangeNotification to synchronize header positioning with scroll content, demonstrating how multiple notifications can target the same coordinator.

Implementing Block-Based Observation

The token-based API preferred for Swift-only code provides type safety and closure-based syntax.

Store the token as an optional NSObjectProtocol property to enable later removal:

private var apiKeyObserver: NSObjectProtocol?

Capture self weakly in the closure to prevent the notification center from retaining the observer:

{ [weak self] notification in
    guard let self = self else { return }
    // Handle notification
}

Specify the main queue explicitly when observing UI-related events:

queue: .main

In AgentService, the observer reloads the API key whenever the keychain updates, ensuring the service always uses current credentials without direct coupling to the storage layer.

Broadcasting Events with Custom Notifications

Define notification names in a centralized extension to avoid string literals:

extension Notification.Name {
    static let anthropicAPIKeyChanged = Notification.Name("anthropicAPIKeyChanged")
}

When posting from AnthropicClient, the object parameter typically references the sender (pass self for instance notifications or nil for global broadcasts). Include userInfo only when passing primitive data that cannot be retrieved from a shared source like the keychain.

Critical Implementation Details

Thread Safety and MainActor

UI-related notifications must be observed on the main thread. The Palmier Pro codebase handles this two ways:

  • Block-based: Specify queue: .main in the addObserver call.
  • Selector-based: Mark the selector method with @MainActor or wrap UI updates in MainActor.assumeIsolated.

Failing to dispatch UI updates to the main thread causes runtime crashes or layout glitches, particularly when observing scroll view notifications that fire during dragging operations.

Memory Management and Observer Lifetime

Selector-based observers are retained by the notification center until explicitly removed. Always call removeObserver(_:) in deinit to prevent dangling pointers after the observing object deallocates.

Token-based observers require storing the returned NSObjectProtocol token. Remove specific observers using removeObserver(_:) with the token, or call removeObserver(_:name:object:) to deregister specific name/object combinations.

The ColorPanelBridge inner class in Sources/PalmierPro/Inspector/Components/ColorField.swift demonstrates this by observing NSColorPanel.colorDidChangeNotification and cleaning up when the color field deinitializes, preventing the bridge from outliving its parent component.

Complete Working Example

Below is a self-contained implementation following the Palmier Pro patterns:

extension Notification.Name {
    static let dataDidUpdate = Notification.Name("dataDidUpdate")
}

class DataStore {
    func update() {
        // Persistence logic...
        NotificationCenter.default.post(name: .dataDidUpdate, object: self)
    }
}

class DataViewController: NSViewController {
    private var updateObserver: NSObjectProtocol?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // Block-based observation with weak self
        updateObserver = NotificationCenter.default.addObserver(
            forName: .dataDidUpdate,
            object: nil,
            queue: .main
        ) { [weak self] _ in
            self?.refreshUI()
        }
    }
    
    private func refreshUI() {
        // Update interface...
    }
    
    deinit {
        if let token = updateObserver {
            NotificationCenter.default.removeObserver(token)
        }
    }
}

Summary

  • Define notification names using Notification.Name extensions to ensure type safety and avoid string literals.
  • Choose selector-based observation when working with NSObject subclasses and AppKit components like TimelineContainerView.
  • Choose block-based observation for Swift-native services like AgentService, storing the token and capturing self weakly.
  • Always post on the main thread or specify queue: .main when UI updates depend on the notification.
  • Remove observers in deinit using the specific token or object reference to prevent memory leaks and dangling pointers.

Frequently Asked Questions

What is the difference between selector-based and block-based NotificationCenter observation?

Selector-based observation uses addObserver(_:selector:name:object:) and requires an Objective-C selector method, making it ideal for NSObject subclasses and AppKit integration as seen in TimelineContainerView.swift. Block-based observation uses addObserver(forName:object:queue:using:) and returns a token object, providing Swift-friendly closure syntax and better control over the dispatch queue, which AgentService uses to handle API key changes.

How do I prevent memory leaks when using NotificationCenter?

Always pair every addObserver call with a corresponding removeObserver call. For selector-based observers, call removeObserver(_:) with the observing object in deinit. For block-based observers, store the returned NSObjectProtocol token and pass it to removeObserver(_:) when the observing object deallocates. The Palmier Pro codebase uses [weak self] in block-based closures to prevent retain cycles.

Can NotificationCenter be used with SwiftUI views?

Yes, but you should use the onReceive modifier or manage the observer lifecycle through a ViewModel or Coordinator object. The TimelineContainerView in Palmier Pro demonstrates how to bridge AppKit notifications into SwiftUI by using an NSViewRepresentable coordinator that handles the observer registration and cleanup separate from the view lifecycle.

How do I pass data between components using NotificationCenter?

Include a userInfo dictionary in the post(name:object:userInfo:) call containing primitive values or Codable objects. However, the Palmier Pro codebase prefers posting notifications without payloads (as in AnthropicClient.swift) and letting observers retrieve fresh data from the canonical source (like the keychain), ensuring consistency when multiple observers react to the same event.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →