A "Swifty" Way of Managing the Dirty Part of a CRUDy Set of Code
In this article
As developers, we have all had to do some CRUDy work. And by CRUDy, I mean developing data management applications that are really just front-ends for Create-Read-Update-Delete operations on application data. They are not sexy, but they help make our data-driven world go 'round.
In this article, I will walk through how I and my team tackled one simple aspect of managing updates to existing data — whether or not a piece of data is "dirty," or has pending changes that are ready to be saved. Our solution utilizes a relatively new feature in Swift and allowed us to track that state alongside the model types, all without modifying these shared types or the syntax to use them.
Depending on your desired UX, sometimes when working through the design for managing updates to data, it can be important for your application to manage whether or not a piece of data is "dirty." That is, whether a piece of data has been modified from its original state and those pending changes need to be persisted to a back-end data store. For instance, on a recent project, we only wanted to show the user a Save button when there were changes to the data to be saved. In this article, we will recreate the solution we used to create a generic reusable means of tracking the dirty state of any model instance.
The app we were working on was a Mac app and was the admin tool for another iOS app that ultimately consumed the data from the app's back-end. We were able to share and reuse the same data model types and data access code between the admin tool app and the core iOS app in the same Xcode project, which was very nice. What we needed was a "Swifty" way for our Admin Tool to manage the dirty state of instances of those existing model structs.
Start simple
The first pass at this did the job, but it resulted in a LOT of boilerplate code to accomplish it. So given a simple model type like:
struct Person {
var name: String
var age: Double
}
We simply added a Boolean isDirty
property to our model structs, and made sure that property was true with didSet
closures on each data property we would be persisting to the back-end data store.
struct Person {
var isDirty = false
var name: String {
didSet {
isDirty = true
}
}
var age: Double {
didSet {
isDirty = true
}
}
}
We also did not like that all that boilerplate was on the model structs themselves. The concept of managing the dirty state was only relevant in the admin tool app, so we wanted to find a way of solving the problem and still keeping the model types thin, simple data containers.
Property wrappers are super Swifty!
In an effort to try to find a Swiftier way of working away at that boilerplate, our first inclination was to look into creating a Property Wrapper we could apply to each of the tracked data properties, similar to the @Published
property wrapper on an ObservableObject. We would still need to have an .isDirty
property on our model struct, but ideally, we would be able to point our property wrapper at that property with a keyPath to set to true
when the value is mutated.
struct Person {
var isDirty = false
@TrackDirty(\.isDirty) var name: String
@TrackDirty(\.isDirty) var age: Double
}
However, property wrappers typically operate in total isolation from their enclosing types. There is a somewhat hidden API to accomplish this type of technique, but unfortunately, it's only available when being used on classes, and not structs, so that wouldn't help in our case.
Custom wrapper type
What we needed was a wrapper type that encapsulated all of the aspects of managing the dirtiness of its wrapped instance, but still made the underlying instance available for us to modify.
We can create our wrapper to manage an instance of this like this:
struct Dirtyable<T> {
var original: T
var isDirty: Bool = false
init(_ initialValue: T) {
original = initialValue
}
}
Then, to use it, we can create a person instance and wrap it with our Dirtyable type:
let me = Person(name: "Matt", age: 45)
var newMe = Dirtyable(me)
newMe.original.age = 30
Clearly, the wrapped person is mutable, but we're not yet doing anything to track those changes to the underlying person. We'll come back to that part.
Improving the call site API with @dynamicMemberLookup
In order to access or change the person's properties, we have to access through the original property. In order to streamline access to the wrapped type's properties, let's use Swift's @dynamicMemberLookup
capabilities to remove the .original
bit from our property accesses. Using this flavor of @dynamicMemberLookup
, we can essentially surface all of the properties of our wrapped objects as if they were our own.
@dynamicMemberLookup
struct Dirtyable<T> {
…
subscript<LocalValue>(dynamicMember keyPath: WritableKeyPath<T, LocalValue>) -> LocalValue {
get {
return original[keyPath: keyPath]
}
set {
original[keyPath: keyPath] = newValue
}
}
}
Now we can directly access the wrapped instance's properties without specifying the .original
property.
newMe.age = 25
print(newMe.original.age) // prints 25
print(newMe.age) // prints 25
Great! And now the setter in the dynamicMember
subscript allow us a seam to also manage an .isDirty
property since changes are coming in.
set {
original[keyPath: keyPath] = newValue
isDirty = true
}
With that in place, we can use our Dirtyable<Person>
instance just like was an original Person
instance, but with the additional dirty tracking as an added bonus.
var newMe = Dirtyable(me)
print(newMe.isDirty) // false
newMe.age = 21
print(newMe.isDirty) // true
Then, when the user taps the Save button, we can trigger the actual update and reset the .isDirty
property to be ready for more changes:
// Save to database
newMe.isDirty = false
Our app uses SwiftUI, so let's use that .isDirty
property to determine whether or not our view should show the Save button to the user:
struct EditPersonView: View {
@State private var newMe = Dirtyable(Person(name: "Matt", age: 45))
var body: some View {
Form {
TextField("name", text: $newMe.name)
HStack {
Text("\(Int(newMe.age))")
Slider(value: $newMe.age, in: 0...150, step: 1.0)
}
if newMe.isDirty {
Button("Save", action: saveToDatabase)
}
}
}
private func saveToBackend() {
// Save to database
newMe.isDirty = false
}
}
Undoing changes
But I think we can add even more utility with our Dirtyable
wrapper. What if, in addition to a Save button, we also wanted to provide a Cancel button that would effectively roll back any pending updates to their original state? Since structs are lightweight, in addition to holding the original instance of our wrapped value, let's also hold another instance that represents our current pending values.
We'll create a copy of the original upon the first property change and mutate that copy. Our dynamicMember
getter then will return the potentially-updated value from the copy, or if it's accessed before the copy is made, return the property from our original. Next, we'll also provide a rollback()
method to ditch the copy and just go back to using the original instance.
@dynamicMemberLookup
struct Dirtyable<T> {
var original: T
var updated: T?
var isDirty: Bool = false
init(_ initialValue: T) {
original = initialValue
}
subscript<LocalValue>(dynamicMember keyPath: WritableKeyPath<T, LocalValue>) -> LocalValue {
get {
if let dirty = updated {
return dirty[keyPath: keyPath]
}
return original[keyPath: keyPath]
}
set {
if updated == nil {
updated = original
}
updated![keyPath: keyPath] = newValue
isDirty = true
}
}
mutating func rollback() {
updated = nil
}
}
Then in our view, we can add our Reset button:
if newMe.isDirty {
Button("Save", action: saveToBackend)
Button("Reset") {
newMe.rollback()
}
}
Next, we add a corollary to our rollback
()
function called commit
()
to clean up after a successful save of our model and at the same time add some more smarts to our .
isDirty
property such that we will not need to manually reset it at all.
@dynamicMemberLookup
struct Dirtyable<T> {
…
var isDirty: Bool { updated != nil }
var current: T { updated ?? original }
…
mutating func commit() {
if let updated = updated {
original = updated
self.updated = nil
}
}
…
}
Now after our saveToBackend
button action persists the new values, we can reset the view's state with the saved version set as the new baseline.
private func saveToBackend() {
guard newMe.isDirty else { return }
save(newMe.current)
newMe.commit()
}
Smarter 'isDirty' with Equatable
This is looking pretty good, but there's still a simple improvement we can make. When we make changes to our model, our .isDirty
property is getting set to true
auto-magically for us. However, if a user were to set the properties back to their original states, our .isDirty
flag stays true
, and the user could then tap the Save button to trigger what would be an unnecessary save operation in our backend.
Let's use a conditional extension on Dirtyable to take advantage of the case when our generic wrapped type happens to conform to Equatable
. In such cases, we'll override our .isDirty
property to only return true
if updated exists and it is indeed different from the original.
extension Dirtyable where T: Equatable {
var isDirty: Bool {
guard let updated = updated else { return false }
return updated != original
}
}
With that in place, if Person
conformed to the Equatable
protocol and the user moves our slider from its original value and then back to its original value, we'll see our Save and Reset buttons appear and disappear accordingly!
The final product
At the end of the day, our Dirtyable wrapper looks like this.
@dynamicMemberLookup
struct Dirtyable<T> {
var original: T
var updated: T?
var isDirty: Bool {
return updated != nil
}
var current: T { updated ?? original }
init(_ initialValue: T) {
original = initialValue
}
subscript<LocalValue>(dynamicMember keyPath: WritableKeyPath<T, LocalValue>) -> LocalValue {
get {
if let dirty = updated {
return dirty[keyPath: keyPath]
}
return original[keyPath: keyPath]
}
set {
if updated == nil {
updated = original
}
updated![keyPath: keyPath] = newValue
}
}
mutating func commit() {
if let updated = updated {
original = updated
self.updated = nil
}
}
mutating func rollback() {
updated = nil
}
}
extension Dirtyable where T: Equatable {
var isDirty: Bool {
guard let updated = updated else { return false }
return updated != original
}
}
With just under 50 lines of library code, we've got a reusable means of encapsulating the management of the dirty state of any model type that doesn't involve making changes to those model types at all.
We can help
Do you or your organization have an application development need? Reach out to us to talk about how we can be of assistance.