DEV Community

Cover image for Tracking Changes in Angular Forms Without Losing Your Mind 🤯
Xhani Manolis Trungu
Xhani Manolis Trungu

Posted on

Tracking Changes in Angular Forms Without Losing Your Mind 🤯

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
    });
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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)