Migrating from Combine to Swift Observation framework and modern async/await patterns. Covers Publisher to AsyncSequence conversion, ObservableObject to @Observable migration, bridging patterns, and reactive code modernization. Use when user asks about Combine migration, ObservableObject to Observable, Publisher to AsyncSequence, or modernizing reactive code.
/plugin marketplace add bluewaves-creations/bluewaves-skills/plugin install swift-apple-dev@bluewaves-skillsThis skill is limited to using the following tools:
Guide for migrating from Combine framework to modern Swift Observation and async/await patterns.
┌─────────────────────────────────────────────────────────────┐
│ COMBINE → MODERN SWIFT │
├─────────────────────────────────────────────────────────────┤
│ │
│ ObservableObject → @Observable │
│ @Published → Regular properties │
│ @ObservedObject → Direct reference │
│ @StateObject → @State │
│ @EnvironmentObject→ @Environment │
│ Publisher → AsyncSequence │
│ sink/assign → for await / async let │
│ Cancellable → Task cancellation │
│ │
└─────────────────────────────────────────────────────────────┘
import Combine
import SwiftUI
class UserViewModel: ObservableObject {
@Published var name: String = ""
@Published var email: String = ""
@Published var isLoading: Bool = false
@Published private(set) var error: Error?
private var cancellables = Set<AnyCancellable>()
init() {
// Debounce name changes for validation
$name
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.removeDuplicates()
.sink { [weak self] name in
self?.validateName(name)
}
.store(in: &cancellables)
}
func save() {
isLoading = true
// Save logic
}
private func validateName(_ name: String) {
// Validation logic
}
}
// SwiftUI usage
struct UserView: View {
@StateObject private var viewModel = UserViewModel()
var body: some View {
Form {
TextField("Name", text: $viewModel.name)
TextField("Email", text: $viewModel.email)
if viewModel.isLoading {
ProgressView()
}
Button("Save") {
viewModel.save()
}
}
}
}
import Observation
import SwiftUI
@Observable
class UserViewModel {
var name: String = ""
var email: String = ""
var isLoading: Bool = false
private(set) var error: Error?
// Debouncing with Task
private var validationTask: Task<Void, Never>?
var nameDidChange: Void {
// Called when name changes
validationTask?.cancel()
validationTask = Task {
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
await validateName(name)
}
}
func save() async {
isLoading = true
defer { isLoading = false }
do {
try await saveToServer()
} catch {
self.error = error
}
}
private func validateName(_ name: String) async {
// Async validation
}
private func saveToServer() async throws {
// Network call
}
}
// SwiftUI usage - simpler!
struct UserView: View {
@State private var viewModel = UserViewModel()
var body: some View {
Form {
TextField("Name", text: $viewModel.name)
.onChange(of: viewModel.name) { _, _ in
_ = viewModel.nameDidChange
}
TextField("Email", text: $viewModel.email)
if viewModel.isLoading {
ProgressView()
}
Button("Save") {
Task { await viewModel.save() }
}
}
}
}
| Combine | Observation |
|---|---|
class ViewModel: ObservableObject | @Observable class ViewModel |
@Published var | var (automatic) |
@StateObject | @State |
@ObservedObject | Direct reference |
@EnvironmentObject | @Environment |
objectWillChange.send() | Automatic |
import Combine
extension Publisher where Failure == Never {
/// Convert any non-failing Publisher to AsyncSequence
var values: AsyncStream<Output> {
AsyncStream { continuation in
let cancellable = self.sink { value in
continuation.yield(value)
}
continuation.onTermination = { _ in
cancellable.cancel()
}
}
}
}
extension Publisher {
/// Convert any Publisher to throwing AsyncSequence
var throwingValues: AsyncThrowingStream<Output, Error> {
AsyncThrowingStream { continuation in
let cancellable = self.sink(
receiveCompletion: { completion in
switch completion {
case .finished:
continuation.finish()
case .failure(let error):
continuation.finish(throwing: error)
}
},
receiveValue: { value in
continuation.yield(value)
}
)
continuation.onTermination = { _ in
cancellable.cancel()
}
}
}
}
class DataService {
private var cancellables = Set<AnyCancellable>()
func startMonitoring() {
NotificationCenter.default
.publisher(for: .NSManagedObjectContextDidSave)
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
.sink { [weak self] notification in
self?.handleContextSave(notification)
}
.store(in: &cancellables)
}
private func handleContextSave(_ notification: Notification) {
// Handle save
}
}
class DataService {
private var monitoringTask: Task<Void, Never>?
func startMonitoring() {
monitoringTask = Task {
// Using new iOS 17+ async notification API
for await notification in NotificationCenter.default.notifications(named: .NSManagedObjectContextDidSave) {
// Built-in debouncing with Task.sleep
try? await Task.sleep(for: .milliseconds(500))
guard !Task.isCancelled else { return }
await handleContextSave(notification)
}
}
}
func stopMonitoring() {
monitoringTask?.cancel()
monitoringTask = nil
}
private func handleContextSave(_ notification: Notification) async {
// Handle save
}
}
// Modern event stream
struct LocationUpdates: AsyncSequence {
typealias Element = CLLocation
struct AsyncIterator: AsyncIteratorProtocol {
let manager: CLLocationManager
var continuation: AsyncStream<CLLocation>.Continuation?
mutating func next() async -> CLLocation? {
// Implementation
return nil
}
}
func makeAsyncIterator() -> AsyncIterator {
AsyncIterator(manager: CLLocationManager())
}
}
// Usage
func trackLocation() async {
for await location in LocationUpdates() {
print("New location: \(location)")
}
}
// BEFORE: Combine
$searchText
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.sink { [weak self] text in
self?.search(text)
}
.store(in: &cancellables)
// AFTER: Task-based debounce
@Observable
class SearchViewModel {
var searchText: String = "" {
didSet { debouncedSearch() }
}
private var searchTask: Task<Void, Never>?
private func debouncedSearch() {
searchTask?.cancel()
searchTask = Task {
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
await performSearch(searchText)
}
}
private func performSearch(_ query: String) async {
// Search implementation
}
}
// BEFORE: Combine
$value
.throttle(for: .seconds(1), scheduler: RunLoop.main, latest: true)
.sink { value in
self.process(value)
}
.store(in: &cancellables)
// AFTER: Task-based throttle
actor Throttler {
private var lastExecution: Date?
private let interval: Duration
init(interval: Duration) {
self.interval = interval
}
func throttle(_ action: @escaping () async -> Void) async {
let now = Date()
if let last = lastExecution {
let elapsed = now.timeIntervalSince(last)
if elapsed < interval.timeInterval {
return // Skip this call
}
}
lastExecution = now
await action()
}
}
extension Duration {
var timeInterval: TimeInterval {
let (seconds, attoseconds) = self.components
return Double(seconds) + Double(attoseconds) / 1e18
}
}
// BEFORE: Combine
Publishers.CombineLatest($firstName, $lastName)
.map { "\($0) \($1)" }
.sink { fullName in
self.fullName = fullName
}
.store(in: &cancellables)
// AFTER: Computed property (simplest)
@Observable
class PersonViewModel {
var firstName: String = ""
var lastName: String = ""
var fullName: String {
"\(firstName) \(lastName)"
}
}
// AFTER: AsyncSequence merge (for streams)
func mergeStreams() async {
async let stream1 = processStream1()
async let stream2 = processStream2()
// Process both concurrently
let results = await (stream1, stream2)
}
// Task group for dynamic merging
func mergeMultiple<T>(_ sequences: [AsyncStream<T>]) -> AsyncStream<T> {
AsyncStream { continuation in
Task {
await withTaskGroup(of: Void.self) { group in
for sequence in sequences {
group.addTask {
for await value in sequence {
continuation.yield(value)
}
}
}
}
continuation.finish()
}
}
}
// BEFORE: Combine
urlSession.dataTaskPublisher(for: url)
.retry(3)
.sink(
receiveCompletion: { _ in },
receiveValue: { data, response in }
)
.store(in: &cancellables)
// AFTER: async/await retry
func fetchWithRetry(url: URL, maxAttempts: Int = 3) async throws -> Data {
var lastError: Error?
for attempt in 1...maxAttempts {
do {
let (data, _) = try await URLSession.shared.data(from: url)
return data
} catch {
lastError = error
if attempt < maxAttempts {
// Exponential backoff
let delay = Duration.seconds(pow(2, Double(attempt - 1)))
try await Task.sleep(for: delay)
}
}
}
throw lastError ?? URLError(.unknown)
}
// BEFORE: Combine
fetchPublisher()
.catch { error -> AnyPublisher<Data, Never> in
return Just(Data()).eraseToAnyPublisher()
}
.sink { data in
self.process(data)
}
.store(in: &cancellables)
// AFTER: async/await
func fetchWithFallback() async -> Data {
do {
return try await fetchData()
} catch {
// Log error
print("Fetch failed: \(error), using fallback")
return Data() // Fallback
}
}
// Or with Result type
func fetchResult() async -> Result<Data, Error> {
do {
let data = try await fetchData()
return .success(data)
} catch {
return .failure(error)
}
}
Sometimes you need to bridge between systems during migration:
import Combine
// Combine Publisher from async function
extension Publisher {
static func fromAsync<T>(_ operation: @escaping () async throws -> T) -> AnyPublisher<T, Error> {
Deferred {
Future { promise in
Task {
do {
let result = try await operation()
promise(.success(result))
} catch {
promise(.failure(error))
}
}
}
}
.eraseToAnyPublisher()
}
}
// Usage
let publisher = Publisher.fromAsync {
try await fetchUserData()
}
// Async function from Combine Publisher
extension Publisher {
func firstValue() async throws -> Output {
try await withCheckedThrowingContinuation { continuation in
var cancellable: AnyCancellable?
cancellable = self.first().sink(
receiveCompletion: { completion in
switch completion {
case .finished:
break
case .failure(let error):
continuation.resume(throwing: error)
}
cancellable?.cancel()
},
receiveValue: { value in
continuation.resume(returning: value)
}
)
}
}
}
// Usage
let user = try await userPublisher.firstValue()
// Phase 1: Keep Combine internally, expose async API
class LegacyService {
private var cancellables = Set<AnyCancellable>()
// Legacy Combine implementation
private func fetchUserCombine() -> AnyPublisher<User, Error> {
// Existing Combine code
URLSession.shared.dataTaskPublisher(for: userURL)
.map(\.data)
.decode(type: User.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
// New async wrapper
func fetchUser() async throws -> User {
try await fetchUserCombine().firstValue()
}
}
// Phase 2: Rewrite internals to async
class ModernService {
func fetchUser() async throws -> User {
let (data, _) = try await URLSession.shared.data(from: userURL)
return try JSONDecoder().decode(User.self, from: data)
}
}
@Observable
class DataViewModel {
enum LoadState<T> {
case idle
case loading
case loaded(T)
case error(Error)
}
var userState: LoadState<User> = .idle
var isLoading: Bool {
if case .loading = userState { return true }
return false
}
var user: User? {
if case .loaded(let user) = userState { return user }
return nil
}
var error: Error? {
if case .error(let error) = userState { return error }
return nil
}
func loadUser() async {
userState = .loading
do {
let user = try await fetchUser()
userState = .loaded(user)
} catch {
userState = .error(error)
}
}
private func fetchUser() async throws -> User {
// Fetch implementation
fatalError()
}
}
struct UserView: View {
@State private var viewModel = DataViewModel()
var body: some View {
Group {
switch viewModel.userState {
case .idle:
Text("Pull to load")
case .loading:
ProgressView()
case .loaded(let user):
UserProfileView(user: user)
case .error(let error):
ErrorView(error: error) {
Task { await viewModel.loadUser() }
}
}
}
.task {
await viewModel.loadUser()
}
}
}
@Observable
class StreamViewModel {
var messages: [Message] = []
private var streamTask: Task<Void, Never>?
func startListening() {
streamTask = Task {
for await message in messageStream() {
messages.append(message)
}
}
}
func stopListening() {
streamTask?.cancel()
}
private func messageStream() -> AsyncStream<Message> {
AsyncStream { continuation in
// Stream implementation
}
}
}
struct MessagesView: View {
@State private var viewModel = StreamViewModel()
var body: some View {
List(viewModel.messages) { message in
MessageRow(message: message)
}
.task {
viewModel.startListening()
}
.onDisappear {
viewModel.stopListening()
}
}
}
□ 1. Identify all ObservableObject classes
□ 2. Convert to @Observable one by one
□ 3. Replace @Published with regular properties
□ 4. Replace @StateObject with @State
□ 5. Replace @ObservedObject with direct reference
□ 6. Replace @EnvironmentObject with @Environment
□ 7. Convert Combine pipelines to async/await
□ 8. Replace cancellables with Task cancellation
□ 9. Update tests to use async/await
□ 10. Remove Combine imports where no longer needed
// GOTCHA 1: @Observable requires class, not struct
@Observable
class ViewModel { } // ✓ Correct
// GOTCHA 2: @State for @Observable in SwiftUI
@State private var viewModel = ViewModel() // ✓ iOS 17+
// GOTCHA 3: Manual observation still possible
@Observable
class Model {
var value: Int = 0
}
// Observe changes manually
let model = Model()
withObservationTracking {
_ = model.value
} onChange: {
print("Value changed!")
}
// GOTCHA 4: Task cancellation is cooperative
Task {
try await Task.sleep(for: .seconds(1))
guard !Task.isCancelled else { return } // Must check!
// Continue work
}
// ✓ Use @Observable for ViewModels
@Observable class ViewModel { }
// ✓ Use async/await for asynchronous operations
func fetch() async throws -> Data
// ✓ Use Task for launching async work from sync context
Button("Load") {
Task { await viewModel.load() }
}
// ✓ Cancel tasks on disappear
.task { await viewModel.startMonitoring() }
// ✗ Don't mix ObservableObject and @Observable
// Choose one pattern per class
// ✗ Don't forget to cancel tasks
// Always store and cancel long-running tasks
// ✗ Don't use Combine for new code (iOS 17+)
// Use async/await instead
// ✗ Don't block the main thread
// Use Task.detached for CPU-intensive work
This skill should be used when the user asks to "create a slash command", "add a command", "write a custom command", "define command arguments", "use command frontmatter", "organize commands", "create command with file references", "interactive command", "use AskUserQuestion in command", or needs guidance on slash command structure, YAML frontmatter fields, dynamic arguments, bash execution in commands, user interaction patterns, or command development best practices for Claude Code.
This skill should be used when the user asks to "create an agent", "add an agent", "write a subagent", "agent frontmatter", "when to use description", "agent examples", "agent tools", "agent colors", "autonomous agent", or needs guidance on agent structure, system prompts, triggering conditions, or agent development best practices for Claude Code plugins.
This skill should be used when the user asks to "create a hook", "add a PreToolUse/PostToolUse/Stop hook", "validate tool use", "implement prompt-based hooks", "use ${CLAUDE_PLUGIN_ROOT}", "set up event-driven automation", "block dangerous commands", or mentions hook events (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification). Provides comprehensive guidance for creating and implementing Claude Code plugin hooks with focus on advanced prompt-based hooks API.