Swift Sharing

repository·main·Indexed 21 days ago

https://github.com/pointfreeco/swift-sharing

A library for synchronizing state across different parts of an app and persisting it to external storage such as UserDefaults or the file system. It provides the @Shared property wrapper for use in SwiftUI views, @Observable models, and UIKit view controllers, supporting persistence strategies like appStorage, fileStorage, and inMemory. The library includes features for deriving shared state, managing dynamic keys via SharedKey, and integrating with external services like Firebase Remote Config.

Tokens
14.6K
Snippets
56
Records
73
Agent score
72%

What's inside Swift Sharing

  1. Overview of the Sharing library

    main
    The Sharing library allows you to share state among different parts of your application and synchronize it with external persistence layers like UserDefaults, the file system, or in-memory storage. It works across various contexts including SwiftUI views, @Observable models, and UIKit view controllers. It is designed to be fully unit testable by providing quarantined storage for tests.
  2. Overview of Swift Sharing

    main
    Swift Sharing is a library designed to instantly share state among different parts of an application and external persistence layers (like User Defaults or the file system). It works across various contexts including SwiftUI views, @Observable models, and UIKit view controllers. It is designed to be completely unit testable by providing quarantined storage for tests.
  3. Explore companion libraries for Swift Sharing

    main

    Swift Sharing is designed to be extensible. Several community-supported libraries are available to provide specific storage, synchronization, or state management capabilities:

    • SharingGRDB: A lightweight replacement for SwiftData and the @Query macro.
    • SharingCloud: Syncs shared data across devices via iCloud.
    • SharingFirestore: Provides real-time synchronization and real-time queries using Firebase's Firestore.
    • SharingRemoteConfig: Provides real-time Remote Config via Firebase.
    • SwiftOperation: A flexible async state management library supporting pagination, retries, deduplication, backoff, and more.
  4. Create a custom persistence strategy via SharedKey

    main

    If UserDefaults or JSON files are insufficient, you can implement a custom persistence strategy by conforming to the SharedKey protocol. This allows you to share state with external systems like remote servers or SQLite databases.

    To implement SharedKey, you must provide:

    1. SharedReaderKey/load(context:continuation:): To load a value from the external system.
    2. SharedReaderKey/subscribe(context:subscriber:): To subscribe to changes in the external system to update the @Shared value.
    3. SharedKey/save(_:context:continuation:): To save a value to the external system.
    4. SharedReaderKey/id: A unique identifier for the state in the external storage.

    If the external system is read-only (e.g., a remote configuration file that the client cannot write to), conform to SharedReaderKey instead of SharedKey.

    public final class CustomSharedKey: SharedKey {
      // Implement load, subscribe, save, and id
    }
    
    // Customary to add a helper for easy access
    extension SharedKey {
      public static func custom<Value>(/* ... */) -> Self
      where Self == CustomSharedKey<Value> {
        CustomSharedKey(/* ... */)
      }
    }
    
    // Usage
    @Shared(.custom(/* ... */)) var myValue: Value
  5. Persistence strategies for @Shared

    main

    The @Shared property wrapper supports several built-in persistence strategies to manage how data is stored and shared:

    1. appStorage: Best for small, simple pieces of data in UserDefaults (e.g., settings).
    2. fileStorage: Best for complex data types that need to be serialized to the file system.
    3. inMemory: Best for sharing data globally within the app lifecycle; data is reset when the app relaunches.
    // appStorage example
    @Shared(.appStorage("soundsOn")) var soundsOn = true
    
    // fileStorage example
    @Shared(.fileStorage(.meetingsURL)) var meetings: [Meeting] = []
    
    // inMemory example
    @Shared(.inMemory("events")) var events: [String] = []
  6. How to safely mutate shared state with withLock

    main

    Shared state in the Sharing library cannot be mutated directly via its wrappedValue because doing so can lead to race conditions and data loss when multiple threads attempt to read, modify, and write the value simultaneously.

    To ensure thread-safe mutation, you must use the withLock method. This method synchronizes access to the underlying shared storage for the duration of the provided lexical scope, ensuring that the read-modify-write cycle is atomic.

    Because @Shared can be used anywhere (not just in @MainActor-bound SwiftUI views), using withLock is a required safety mechanism to prevent bugs in @Observable models, UIKit controllers, or background actors.

    await withTaskGroup(of: Void.self) { group in
      for _ in 1...1_000 {
        group.addTask {
          $count.withLock { value in
            value += 1
          }
        }
      }
    }
  7. Use built-in persistence strategies with @Shared

    main

    The library provides three built-in strategies for the @Shared property wrapper:

    1. appStorage: Best for small, simple data types stored in UserDefaults (e.g., settings).
    2. fileStorage: Best for complex data types that need to be serialized to the file system.
    3. inMemory: Best for sharing data globally within the app lifecycle; data is reset when the app relaunches.

    See PersistenceStrategies documentation for details on custom strategies.

    // appStorage for simple settings
    @Shared(.appStorage("soundsOn")) var soundsOn = true
    @Shared(.appStorage("hapticsOn")) var hapticsOn = true
    @Shared(.appStorage("userSort")) var userSort = UserSort.name
    
    // fileStorage for complex data
    @Shared(.fileStorage(.meetingsURL)) var meetings: [Meeting] = []
    
    // inMemory for global app state (reset on relaunch)
    @Shared(.inMemory("events")) var events: [String] = []
  8. Derive sub-parts of shared state using projected values

    main

    You can derive a smaller piece of shared state from a larger @Shared property by using its projectedValue via the $ syntax. This allows child features to hold onto only the specific data they need (e.g., Shared<String>) rather than the entire parent state (e.g., Shared<SignUpData>).

    When a parent feature passes a derived value, any changes made by the child are automatically reflected in the parent's source of truth. This also preserves any persistence strategies (like .fileStorage) defined on the parent's property, allowing the child to remain agnostic of how the data is stored.

    // Parent holds the full state
    @Observable
    class ParentModel {
      @ObservationIgnored
      @Shared var signUpData: SignUpData
    
      func nextButtonTapped() {
        // Derive a sub-part using $ syntax and dot-chaining
        path.append(
          PhoneNumberModel(phoneNumber: $signUpData.phoneNumber)
        )
      }
    }
    
    // Child holds only the sub-part
    @Observable
    class PhoneNumberModel {
      @ObservationIgnored
      @Shared var phoneNumber: String
    }
  9. How to use @Shared with @Observable models

    main

    You can use the @Shared property wrapper inside @Observable classes to synchronize state between different models.

    Important Note: Because Swift macros do not play nicely with property wrappers, you must annotate each @Shared property with @ObservationIgnored. SwiftUI views will still update correctly because @Shared handles its own observation internally.

    // MeetingsList.swift
    @Observable
    class MeetingsListModel {
      @ObservationIgnored
      @Shared(.fileStorage(.meetingsURL)) var meetings: [Meeting] = []
    }
    
    // ArchivedMeetings.swift
    @Observable
    class ArchivedMeetingsModel {
      @ObservationIgnored
      @Shared(.fileStorage(.meetingsURL)) var meetings: [Meeting] = []
    }
  10. Use @Shared to synchronize state across your application

    main

    The @Shared property wrapper allows multiple parts of your application (e.g., different observable models, SwiftUI views, or UIKit view controllers) to hold onto and synchronize the same piece of mutable data. When one instance modifies the value, all other instances observing that same key will instantly see the changes. This works even for external changes, such as a file being modified on disk.

    Important Note for Observable Models: Because Swift macros do not play nicely with property wrappers, you must annotate each @Shared property with @ObservationIgnored when using it inside an @Observable class. SwiftUI views will still update correctly because @Shared handles its own observation internally.

    @Observable
    class MeetingsListModel {
      @ObservationIgnored
      @Shared(.fileStorage(.meetingsURL)) var meetings: [Meeting] = []
    }
    
    @Observable
    class ArchivedMeetingsModel {
      @ObservationIgnored
      @Shared(.fileStorage(.meetingsURL)) var meetings: [Meeting] = []
    }