I've found the best way to handle this problem is a variation on "lift the state up", but instead of binding synchronous listeners to state changes in the model, have these state changes mark any involved views as "dirty". Then, after all events have been processed for the current run loop, go through all the dirty views and call a single update function on each.
For the example given in the article, the update function could look something like
def View.update():
if model.lightTurnedOn:
self.backgroundColor = red
else:
self.backgroundColor = ibmGray
This way, all view property changes happen in one place, where you can read the code and understand how the view will appear in each possible state. Circular listener loops are impossible, and view properties for animations can even be computed by calling update twice (once before and once after the state change).
That's basically what modern JS frontend frameworks do as well. I suppose as soon as something becomes too complex, keeping track of all interactions is just too complicated and rerendering the entire world efficiently looks like an easier problem to deal with.
I like the current trend of going back to renderless components as well. This way you separate the state changes from the way it looks like. Feels like each component is a miniature MVC framework with front and a back.
> I suppose as soon as something becomes too complex, keeping track of all interactions is just too complicated and rerendering the entire world efficiently looks like an easier problem to deal with.
In fact, it can actually be less work, since you're coalescing changes into a single update() call rather than sprinkling them across observer callbacks. Also, if your update function starts running too slowly, you can always make it more precise by keeping track of which states have changed internally to the view. For example, if setting the background color takes a long time for whatever reason, you can do something like this:
def View.update():
if self.lightWasTurnedOn != model.lightTurnedOn:
if model.lightTurnedOn:
self.backgroundColor = red
else:
self.backgroundColor = ibmGray
self.lightWasTurnedOn = model.lightTurnedOn
Now backgroundColor will only be set if lightTurnedOn actually changed since the last update.
FWIW this is a common approach - you can see it even in Win32 API's invalidation mode (which goes back to the 80s) where you mark parts of the UI as "invalid" and eventually (when you start pumping events again in the main loop) the window system combines all the invalid areas and sends you a message to paint ("validate") the window.
Several toolkits and frameworks provide for "after all other events have been processed" hooks/events for such logic, e.g. Delphi has the TApplication.OnIdle event and later versions as well as Lazarus/FreePascal have dedicated controls for this event and "idle timers" meant to be used for updating any invalidated parts of the UI after all other events have finished. Similarly wxWidgets has wxEVT_UPDATE_UI and i'm almost certain that Qt has something similar too - though i can't find it now.
For the example given in the article, the update function could look something like
This way, all view property changes happen in one place, where you can read the code and understand how the view will appear in each possible state. Circular listener loops are impossible, and view properties for animations can even be computed by calling update twice (once before and once after the state change).