Releases: mergesort/Boutique
We all should be a little more dynamic
This release restores a behavior where updating a StoredValue or SecurelyStoredValue would update any View that was referencing those values.
Don't let these lil release notes drown out all of the changes in Boutique 3.0, in case you missed that big update.
Boutique 3.0MG
Boutique 3.0 is the biggest update since the release of Boutique 2.0, which was released over two years ago. Boutique 3.0 isn't a complete rewrite — I love how Boutique works and the last thing I want to do is change everything about it — but it's a series of important updates and changes. I want to continue advancing what Boutique can do, so Boutique has been modernized, the simple APIs have stayed the same, with rewritten internals to make the library even better, simpler, and more flexible.
I want to emphasize, the Boutique you know and love is not changing — it's being extended to become even better, and here's how.
-
Goodbye
ObservableObject, hello@Observable. You can now use Boutique'sStore,@StoredValue, and@SecurelyStoredValuewith@Observable. This makes your code cleaner, faster, and simpler to reason about. Boutique 3.0 is even easier to integrate into an app, without any new concepts to learn. -
Swift 6 support. Swift 6 is a big change, and you can continue using Boutique in Swift 5 projects — no rush to upgrade. Swift 6's focus on data races and memory safety have helped me track down some rare race conditions that could have previously occurred. Boutique's
Store,@StoredValue, and@SecurelyStoredValueare now bound to@MainActor, to prevent loss or inconsistency of data. -
New APIs, for now and later. By cleaning up Boutique's internals, I've laid the groundwork for new features that will help Boutique match all that SwiftData has to offer. It's always been a goal for Boutique to be perfect out of the box for 95% of projects, but my goal now is to get Boutique to 99% — making it a fit for almost every project.
Here is a list of important high level changes, eschewing details buried in the code.
Store & @Stored
- Boutique no longer depends on Combine, and values are now published through
AsyncStream. This makes observing the Store's items cleaner, and you can now observe changes throughonChangerather thanonReceive.
// Before
.onReceive(notesController.$notes.$items, perform: {
self.notes = $0
})
// After
.onChange(of: self.notesController.notes, initial: true, { _, newValue in
self.notes = newValue
})
// Note: The `initial` property being set to true is not required, but it is important for reproducing the previous behavior where `$items` would be called in `onReceive` where the Store was empty.- A new
eventsproperty publishesStoreEventvalues. This Granular Events Tracking API allows you to observe individual changes when aStoreisinitialized,loaded, and wheninsertandremoveoperations occur.
func monitorNotesStoreEvents() async {
for await event in self.notesController.$notes.events {
switch event.operation {
case .initialized:
print("[Store Event: initialized] Our Notes Store has initialized")
case .loaded:
print("[Store Event: loaded] Our Notes Store has loaded with notes", event.items)
case .insert:
print("[Store Event: insert] Our Notes Store inserted notes", event.items)
case .remove:
print("[Store Event: remove] Our Notes Store removed notes", event.items)
}
}
}@StoredValue & @SecurelyStoredValue
- By implementing Observation tracking, you can now create a
Store,StoredValue, orSecurelyStoredValueinside of an@Observabletype. Please note that you will have to add@ObservationIgnoredto your properties, but don't worry, they will still be observed properly.
@Observable
final class AppState {
@ObservationIgnored
@StoredValue(key: "isRedPandaModeEnabled")
var isRedPandaModeEnabled = false
@ObservationIgnored
@SecurelyStoredValue(key: "redPandaData")
var redPandaData: RedPandaData?
}- Due to
ObservableObjectrestrictions, we previously could not break down anObservableObjectinto smaller objects while still observing their changes. Thanks to@Observable, we can now enforce a cleaner separation of concerns by splitting up large objects into smaller ones.
// Before
@MainActor
@Observable
final class Preferences {
@ObservationIgnored
@StoredValue(key: "hasSoundEffectsEnabled", storage: Preferences.store)
public var hasSoundEffectsEnabled = false
@ObservationIgnored
@StoredValue(key: "hasHapticsEnabled", storage: Preferences.store)
public var hasHapticsEnabled = true
@ObservationIgnored
@StoredValue(key: "likesRedPandas", storage: Preferences.store)
public var likesRedPandas = true
}
// After
@Observable
final class Preferences {
var userExperiencePreferences = UserExperiencePreferences()
var redPandaPreferences = RedPandaPreferences()
}
@MainActor
@Observable
final class UserExperiencePreferences {
@ObservationIgnored
@StoredValue(key: "hasSoundEffectsEnabled")
public var hasSoundEffectsEnabled = false
@ObservationIgnored
@StoredValue(key: "hasHapticsEnabled")
public var hasHapticsEnabled = true
}
@MainActor
@Observable
final class RedPandaPreferences {
@ObservationIgnored
@StoredValue(key: "isRedPandaFan")
public var isRedPandaFan = true
}- Observing changes for
StoredValueandSecurelyStoredValueis now even simpler, thanks to the newvaluesproperty. Just like you can observe changes withStored,storedValue.valuesvends anAsyncStream, which you can monitor for changes anywhere in your app.
for await value in preferences.$hasHapticsEnabled.values {
print("Haptics are enabled:", value)
}- You can now use
StoredValueandSecurelyStoredValuewithout a property wrapper, thanks to new initializers. This makes them usable in any object, not just one that has the@Observablemacro.
// StoredValue.swift
// NEW
public convenience init(key: String, default defaultValue: Item, storage userDefaults: UserDefaults = UserDefaults.standard)
// SecurelyStoredValue.swift
// SAME AS BEFORE
public init(key: String, service: KeychainService? = nil, group: KeychainGroup? = nil)MainActor
The Store, StoredValue, and SecurelyStoredValue are now all bound to the @MainActor. This may seem like it could cause performance issues, but I'm confident that is not the case Boutique or apps using Boutique.
It's important to remember that the @MainActor annotation dictates how synchronous work should be done within a scope (types, functions, etc), not whether asynchronous work will run on the main thread. If you have heavy synchronous work within a type that's annotated @MainActor, then you are liable to seeing performance issues. But if you have heavy asynchronous work, then you will not incur any performance issues unless that asynchronous work is happening on the main thread because the callee said to perform it on the main thread. The @MainActor annotation can and will not force asynchronous work such as network requests or heavy data processing to happen on the main thread, that will only happen if the code you're calling into runs on the main thread.
This is where the architecture of Boutique and the underlying framework Bodega matter. All of of the code in Bodega is async and never calls to the main thread, as is almost every single line of Boutique. Even the synchronous work in Boutique is not heavy, it is all working on data in memory, and calls out to async work that will happen on a background thread. This means that all of the heavy work Boutique does is ultimately on a background thread, even with the Store having a @MainActor annotation.
New onStoreDidLoad Methods
I've added two new onStoreDidLoad methods which make it easier to be notified when Store is loaded, and to run events or change Views based on that.
.onStoreDidLoad(self.notesController.$notes, onLoad: {
print("Loaded notes")
}, onError: { error in
print("Failed to load notes", error)
})
.onStoreDidLoad(
self.notesController.$notes,
update: $itemsHaveLoaded,
onError: { error in
print("Failed to load notes", error)
}
)Notes & Deprecations
- Boutique's minimum deployment target is now iOS 17/macOS 14, the first versions of iOS and macOS that support
@Observable. - Boutique is now ready for Swift 6. 🥳
AsyncStoreValuehas been deprecated, as it was not widely used after its initial introduction.- The
store.addfunction is now fully deprecated. Any code callingstore.addshould instead callstore.insert. - By removing the dependency on Combine, Boutique can explore adding Linux support, but it is not officially supported yet.
I welcome any feedback or suggestions for Boutique 3.0, especially if you integrate Boutique into one of your projects and find ways to improve it! ❤️
Boutique 3.0 RC 1: Final Touches
Changes
- Adding two new
onStoreDidLoadmethods which make it easier to be notified when a Store is loaded - The internal
Store.Operationis now bound to the@MainActor. AsyncValueSubjectnow has internal locking, to provideSendableconformance.
I had a working session with @mattmassicotte to go over Boutique's concurrency model in great detail, and feel confident about the choices made for the next big update. I've spent a lot of time validating the project in multiple apps, and strongly believe that Boutique 3 is ready for production use.
The next release will be a release focused on updating Boutique's documentation, so I can officially release Boutique 3. 🥳
Cache Rules Everything Around Me
This release includes a fix courtesy of @harlanhaskins to improve the performance of @StoredValue by caching and retrieving the values correctly — unlike what I was doing — which was caching and retrieving the values incorrectly.
Await Your Turn To Enter The Store
The suggested API for tracking when a Store has finished loading has always been, to put it politely, a little clunky. My suggestion to date has been to create a task, and await the completion of Store.itemsHaveLoaded() function, like this:
.task({
do {
try await self.$items.itemsHaveLoaded()
} catch {
log.error("Failed to load items", error)
}
})Now though, there is a more intuitive API, using the newly created .onStoreDidLoad function. This code produces functionally identical results to the method above, without the indirection of spinning up a specifically-formatted task.
.onStoreDidLoad(
self.$items,
update: $itemsHaveLoaded,
onError: { error in
log.error("Failed to load items", error)
}
)Additionally, there is now a variant that allows you to execute code you like when the Store finishes loading, like so:
.onStoreDidLoad(
self.$items,
onLoad: {
self.items = self.filteredItems(self.items)
},
onError: { error in
log.error("Failed to load items", error)
}
)
Additional documentation available here.
Guess I Don't Know How To Use Git
This version reverts a merge of Boutique 3.0 API changes that were accidentally added into the 2.4.6 release.
Maybe I'll learn how to use git in 2025, happy new year! 🥳
Remove All, Not Some
This release is a simple fix for #70, courtesy of help from @zhigang1992.
- When calling
.removeAll().insert().run()in a chained manner, all of the items will correctly be removed from the Store every time, rather than a subset of the items that were being inserted.
Boutique 3.0 Beta 2: Lil update with lil fixes
This release is a simple fix for #70, courtesy of help from @zhigang1992.
- When calling
.removeAll().insert().run()in a chained manner, all of the items will correctly be removed from the Store every time, rather than a subset of the items that were being inserted.
Boutique 3.0 Beta 1: An Observable Girl, Living In An Observable World
Boutique 3.0 is the biggest update to Boutique since the release of Boutique 2.0, just over two years ago. Boutique 3.0 isn't a complete rewrite — I love how Boutique works and the last thing I want to do is change everything about it. But I want to continue advancing what Boutique can do, so Boutique has been modernized with rewritten internals to make it even better, simpler, and more flexible.
I want to emphasize, the Boutique you know and love is not changing — it's being extended to become even better, and here's how.
-
Goodbye
ObservableObject, hello@Observable. You can now use Boutique'sStore,@StoredValue, and@SecurelyStoredValuewith@Observable. This makes your code cleaner, faster, and simpler to reason about. Boutique 3.0 is even easier to integrate into an app, without any additional concepts to learn. -
Swift 6 support. Swift 6 is a big change, and you can continue using Boutique in Swift 5 projects — no rush to upgrade. The Swift 6 data race safety upgrades have helped me track down some rare race conditions that may have occurred. Boutique's
Store,@StoredValue, and@SecurelyStoredValueare now bound to @mainactor, to prevent loss or inconsistency of data. -
New APIs, for now and later. By cleaning up Boutique's internals, I've laid the groundwork for new features that will help Boutique match all that SwiftData has to offer. It's always been a goal for Boutique to be perfect out of the box for 95% of projects, but my goal now is to get Boutique to 99% — making it a fit for almost every project.
Here is a list of important high level changes, eschewing details buried in the code.
Store & @Stored
- Boutique no longer depends on Combine, and values are now published through
AsyncStream. This makes observing the Store's items cleaner, and you can now observe changes throughonChangerather thanonReceive.
// Before
.onReceive(richNotesController.$notes.$items, perform: {
self.notes = $0
})
// After
.onChange(of: self.richNotesController.notes, initial: true, { _, newValue in
self.notes = newValue
})
// Note: The `initial` property being set to true is not required, but it is important for reproducing the previous behavior where `$items` would be called in `onReceive` where the Store was empty.- A new
eventsproperty publishesStoreEventvalues, allowing you to observe individual changes when aStoreisinitialized,loaded, and wheninsertandremoveoperations occur.
func monitorNotesStoreEvents() async {
for await event in self.richNotesController.$notes.events {
switch event.operation {
case .initialized:
print("[Store Event: initialized] Our Notes Store has initialized")
case .loaded:
print("[Store Event: loaded] Our Notes Store has loaded with notes", event.items)
case .insert:
print("[Store Event: insert] Our Notes Store inserted notes", event.items)
case .remove:
print("[Store Event: remove] Our Notes Store removed notes", event.items)
}
}
}@StoredValue & @SecurelyStoredValue
- By implementing Observation tracking, you can now create a
Store,StoredValue, orSecurelyStoredValueinside of an@Observabletype. Please note that you will have to add@ObservationIgnoredto your properties, but don't worry, they will still be observed properly.
@Observable
final class AppState {
@ObservationIgnored
@StoredValue(key: "isRedPandaModeEnabled")
var isRedPandaModeEnabled = false
@ObservationIgnored
@SecurelyStoredValue(key: "redPandaData")
var redPandaData: RedPandaData?
}- Due to
ObservableObjectrestrictions, we previously could not break down anObservableObjectinto smaller objects while still observing their changes. Thanks to@Observable, we can now enforce a cleaner separation of concerns by splitting up large objects into smaller ones.
// Before
@MainActor
@Observable
final class Preferences {
@ObservationIgnored
@StoredValue(key: "hasSoundEffectsEnabled", storage: Preferences.store)
public var hasSoundEffectsEnabled = false
@ObservationIgnored
@StoredValue(key: "hasHapticsEnabled", storage: Preferences.store)
public var hasHapticsEnabled = true
@ObservationIgnored
@StoredValue(key: "likesRedPandas", storage: Preferences.store)
public var likesRedPandas = true
}
// After
@Observable
final class Preferences {
var userExperiencePreferences = UserExperiencePreferences()
var redPandaPreferences = RedPandaPreferences()
}
@MainActor
@Observable
final class UserExperiencePreferences {
@ObservationIgnored
@StoredValue(key: "hasSoundEffectsEnabled")
public var hasSoundEffectsEnabled = false
@ObservationIgnored
@StoredValue(key: "hasHapticsEnabled")
public var hasHapticsEnabled = true
}
@MainActor
@Observable
final class RedPandaPreferences {
@ObservationIgnored
@StoredValue(key: "isRedPandaFan")
public var isRedPandaFan = true
}- You can now use
StoredValueandSecurelyStoredValuewithout a property wrapper, thanks to new initializers. This makes them usable in any object, not just one that has the@Observablemacro.
// StoredValue.swift
// NEW
public convenience init(key: String, default defaultValue: Item, storage userDefaults: UserDefaults = UserDefaults.standard)
// SecurelyStoredValue.swift
// SAME AS BEFORE
public init(key: String, service: KeychainService? = nil, group: KeychainGroup? = nil)Notes & Deprecations
- Boutique's minimum deployment target is now iOS 17/macOS 14, the first versions of iOS and macOS that support
@Observable. AsyncStoreValuehas been deprecated, as it was not widely used after its initial introduction.- The
store.addfunction is now fully deprecated. Any code callingstore.addshould instead callstore.insert. - By removing the dependency on Combine, Boutique can explore adding Linux support, but it is not officially supported yet.
Todo
Before Boutique 3.0 is officially released, I still need to:
- Update all documentation to reflect the change from
ObservableObjectto@Observable. - Ensure all tests pass (Boutique's tests have been rewritten with the new Swift Testing framework and currently pass, but only when run with the new
.serializedtrait.) - Integrate Boutique 3.0 into existing projects to ensure it works as expected with equal or better performance than Boutique 2.0.
I welcome any feedback or suggestions for Boutique 3.0, especially if you integrate Boutique into one of your projects and find ways to improve it! ❤️
Ambiguity Shambiguity
This release updates Boutique to Bodega 2.1.3, to resolve an ambiguous reference to Expression which was added to Foundation in iOS 18/macOS 15. Thank you @samalone for the help!