If you’ve spent more than five minutes as an Angular developer, you’ve probably wrestled with forms. They’re everywhere — login screens, checkout flows, massive onboarding wizards that make you question life choices.
And hey, don’t get me wrong — Angular forms are powerful. But let’s be real: sometimes you just wanna know what actually changed without swimming through a sea of boilerplate subscriptions and giant form objects. Like, if a user just updated their street name, why should you care about the fact their hobbies array is still intact?
That’s exactly the itch I had, and — spoiler alert — I scratched it with a neat little utility that tracks changes like a pro.
Why the Usual Way Feels Like Overkill 🐘💨
Sure, you can always subscribe to formControl.valueChanges. Easy peasy… until you’ve got a form with a nested FormGroup for addresses, a FormArray for hobbies, and who knows what else.
The problem? A single change anywhere in that giant form blasts the whole form value at you. Boom. Every time. All of it.
It’s like asking someone “Did you move your desk a little?” and they respond by shipping you the blueprint of the entire office building. Not helpful.
Enter the Hero: A Smart Diff Utility 🦸♂️
So here’s the cool part. Instead of drowning in form values, I cooked up a simple utility that does two magical things:
Performance win: It waits for the user to pause typing before it even bothers checking changes. (Because nobody wants to diff JSON objects on every keystroke.
Crystal clarity: It gives you a clean, tidy “diff” object showing only what actually changed.
Think of it like having a friend who only tells you the juicy gossip, not the whole town’s history.
The Magic Sauce 🍝 (a.k.a. Code)
Here’s the core of our utility:
/**
* Recursively computes a "diff" object between two values.
* Returns null if the values are identical.
* For objects, it returns a new object with only the changed properties.
* For arrays, it returns a new array with a diff for each element.
*/
function getDiff(original: any, current: any): any {
if (original === current) {
return null;
}
// Handle nested objects (FormGroups)
if (original !== null && typeof original === 'object' && !Array.isArray(original) &&
current !== null && typeof current === 'object' && !Array.isArray(current)) {
const diff: any = {};
let hasChanges = false;
for (const key in current) {
if (Object.prototype.hasOwnProperty.call(current, key)) {
const itemDiff = getDiff(original[key], current[key]);
if (itemDiff !== null) {
diff[key] = itemDiff;
hasChanges = true;
}
}
}
return hasChanges ? diff : null;
}
// Handle arrays (FormArrays)
if (Array.isArray(original) && Array.isArray(current)) {
const diff: any[] = [];
let hasChanges = false;
const maxLength = Math.max(original.length, current.length);
for (let i = 0; i < maxLength; i++) {
const itemDiff = getDiff(original[i], current[i]);
diff[i] = itemDiff;
if (itemDiff !== null) {
hasChanges = true;
}
}
return hasChanges ? diff : null;
}
// Handle primitive types
return current;
}
And here’s the wrapper that ties it all together:
export function trackFormChanges(control: AbstractControl, initialValue: any): Subscription {
return control.valueChanges
.pipe(debounceTime(300))
.subscribe(currentValue => {
const diff = getDiff(initialValue, currentValue);
console.log("Changes detected:", diff);
});
}
Breakdown of the Code
- getDiff(original, current): This is the heart of the utility. It's a recursive function that compares the original form value (our baseline) with the current value.
- If the values are identical, it returns null.
- If they are objects (FormGroups), it iterates over the keys and calls itself on each property to find nested changes. It only adds properties to the diff object if a change is found.
- If they are arrays (FormArrays), it iterates over the elements and calls itself to find changes.
- For primitive values (strings, numbers), it simply returns the new value.
- trackFormChanges(control, initialValue): This is the public function you'll use in your component.
- It takes the form control you want to track and its initialValue.
- It subscribes to the valueChanges observable of the root control.
- The debounceTime(300) operator waits for 300 milliseconds of inactivity before emitting a value. This is crucial for performance, as it prevents the diff function from running on every single keystroke.
- Once a value is emitted, it calls getDiff and logs the resulting diff object.
Why I Fell in Love with This 💘
The first time I tried it, I was building a user profile form with nested groups. Normally, I’d have to check dirty states on every field like a detective with too much coffee. But with this? One neat diff object, clear as day.
Suddenly, enabling a “Save” button only when something actually changed went from a gnarly headache to a one-liner:
<button [disabled]="!hasChanges">Save</button>
Chef’s kiss. 👌
A Real-Life Example: User Profile Form 🧑💻
Here’s how it looks in action:
@Component({...})
export class UserProfileComponent implements OnInit, OnDestroy {
userForm: FormGroup;
private formSubscription: Subscription;
public hasChanges = false;
constructor(private fb: FormBuilder) {
this.userForm = this.fb.group({
name: ['John Doe', Validators.required],
address: this.fb.group({
street: ['123 Angular Ave'],
city: ['Codeville']
})
});
}
ngOnInit() {
const initialValue = this.userForm;
this.formSubscription = this.userForm.valueChanges
.pipe(debounceTime(300))
.subscribe(() => {
const diff = getDiff(initialValue, this.userForm.value);
this.hasChanges = diff !== null;
});
}
ngOnDestroy() {
if (this.formSubscription) {
this.formSubscription.unsubscribe();
}
}
onSave() {
if (this.hasChanges) {
console.log('Saving changes:', getDiff(this.userForm, this.userForm.value));
}
}
}
It’s simple, clean, and doesn’t leave you second-guessing what’s happening under the hood.
Comments are welcome for code suggestions, or any better idea (for example making it a custom rxjs operator maybe ?? 🎁).
I would love to hear any personal experiences on this matter.
If you feel this article helped you even a little bit , please support by sharing it.
Wrapping Up 🎁
Forms can be messy, but tracking changes doesn’t have to be. With a smart recursive diff and a little debounce magic, you get a reliable, testable, and sanity-saving way to know exactly what’s changed — nothing more, nothing less.
I’ve been using this trick across projects, and honestly, I don’t want to go back. If you’re tired of wrestling with dirty and touched states, give this a spin.
Who knows — you might just fall in love with Angular forms again. (Okay, maybe that’s pushing it. 😅)
Top comments (0)