Active Record dirty tracking in after_commit hooks

Rails provides a convenient way to track changes in Active Record models with the ActiveRecord::AttributeMethods::Dirty module. This allows us to check what changes are going to be persisted to the database or the latest changes that were persisted.

These methods can be useful in Active Record hooks so that you can run certain hooks only when certain attributes change. For example:

class Book < ApplicationRecord
  after_commit do
    do_something if saved_change_to_title?
  end
end

Though there is one little quirk when using this inside after_commit hooks. When an explicit transaction is started, and the model is saved multiple times within the transaction, the after_commit hook will only be called once.

That makes sense because these updates are committed at the same time at the end of the transaction. But what happens to Active Record’s dirty tracking? It would only reflect the changes in the latest save because dirty tracking is reset after every save even if they’re not committed yet.

So for example we have something like this:

book = Book.find(1)

Book.transaction do
  book.update!(title: 'A new title')
  book.update!(updated_at: Time.current)
end

When the after_commit hook is called, saved_change_to_title? will return false because saved_changes will only contain the changes to updated_at.

One way to work around this is to do our own dirty tracking that is cleared after commit:

class Book < ApplicationRecord
  after_save do
    @title_changed = true if saved_change_to_title?
  end

  after_commit do
    do_something if @title_changed
    @title_changed = nil
  end
end

This allows us to run after_commit hooks conditionally based on attribute changes.