Let’s get right to it. What are Angular’s “directives” and why would I make a custom directive? We’re going to begin investigating the source code of mobx-angular, and along the way we will learn about Angular’s structural directives.
The code I created during this project is on my Github at https://github.com/xerocross/angular-examples on the branch mobx-computed-values. You can simply view the relevant source files directly here: source. But for better results I suggest you clone a local copy of my repo, install it, and play with it. Open your own code editor while you read and launch the Angular live server ng serve --open
to see the results of changes you make immediately.
Much in keeping with the Angular spirit, the word “directive” refers to some kind of class from which inherit components, structural directives, and attribute directives (and maybe more stuff for all I know). If I mean component, then I’ll say component. I’m not super-clear yet on the difference between structural directives and attribute directives. I suppose we will figure that out as we go. I have noticed that a working example of mobx-angular I saw used a directive called *mobxAutorun
and it appeared to be necessary for the whole thing to function as expected. So let’s get into it.
Mobx-angular is not mine. The person in charge of that project appears to be Adam Klein. Since he might change the package between this writing and when someone else later reads my blog post, I have forked the project into my own github directory. My copy is here: https://github.com/xerocross/mobx-angular. This is a museum copy I have no intention of changing. If I think I can actually improve the mobx-angular project, I will submit a pull request to Adam Klein for his consideration.
Here’s how I’m setting up my workstation for this investigation. I’m cloning a local copy of the fork of mobx-angular I just created. To do that, you execute git clone https://github.com/xerocross/mobx-angular.git cross-mobx-angular
. This clones the project into a new directory called “cross-mobx-angular”. I only pulled it so I can examine the source code. I’m not interested in building it. So now I’m just going to open that folder for view in my favorite code editor, VS Code. The source files are in the lib directory. I see a mobx-angular.ts file and a directives directory with more source files. The files inside directives get imported into mobx-angular.ts and mobx-angular.ts is the top-level source file. We will get into that further in a moment.
I also want an example project to play with. I’m going back to my angular-examples project. Recall this project is hosted on Github at https://github.com/xerocross/angular-examples. I have created a new branch from master for today’s study. Little Git tip: in Git it matters what branch you are on when you make a new branch. The branch you are on already is where the new branch starts branching out from. The branch for this lesson is mobx-computed-values. For this branch, I have added mobx and mobx-angular as dependencies.
I’ll say it again: if you are following along with me, you may want to also have a window open displaying my angular-examples project on the correct branch. We will start with an example I already discussed in Lesson 2. Building an initial example isn’t really our focus here, so I won’t belabor that point as hard. Some day I might even get used to updating three different files to bind an input to a controller variable.
Quick summary: in angular-examples branch mobx-computed-values we have a little widget where you can type in a series of comma-separated numbers. The logic is in a file called sequence-explorer.component.ts. We’re using the template style two-way data binding here since the reactive forms method seemed like needless complexity. The template has an input that gets processed by two computed values. It also has another input that is extraneous. As you interact with the first input, you can see that the computed values get executed. If you watch the console and interact with the extraneous input, you see that those computed values do not get re-computed. That is as expected.
I have noted the following unexpected behavior though. Suppose we have a state variable A and computed value B that depends on A and a computed value C that depends on B (with no direct relationship to A itself). The logic flows like A => B => C. In this situation, changes to A do cause C to be re-computed even if B is unchanged by the new value of A. That’s not ideal. If C only references B and B is unchanged, then C does not need to be re-computed, and it should not be.
The relevant tools we are using from mobx-angular are these: *mobxAutorun, MobxAngularModule, observable and computed. Let’s explore these and see what is going on.
MobxAngularModule is just something you import that includes the references. The observable and computed functions are what we use as decorators in the class to mark our variables. Moreover, those decorator functions come directly from mobx itself. The magic of mobx-angular appears to be happening inside the MobxAutorun directive.
Reading, I find that the asterisk (*) is not actually part of the name of the directive. The directive is called MobxAutorun and in Angular the * marks it as a structural directive. Exactly what this * operator does doesn’t look very complicated. You can read further here: https://angular.io/guide/structural-directives#asterisk. I’m more interested in learning how MobxAutorun does what it does.
The controller class MobxAutorunDirective is quite short. Take a look now if you are following along. The constructor includes three arguments it calls templateRef, viewContainer, and renderer. That looks like built-in Angular stuff for what happens when you declare a structural directive on something in a template. I’m reading from the docs here: https://angular.io/guide/structural-directives#write-a-structural-directive. Yeah. These references get passed into the constructor automatically when a structural directive is constructed.
On that page, I’m reading this example of a setter function in the directive controller:
@Input() set appUnless(condition: boolean) {
if (!condition && !this.hasView) {
this.viewContainer.createEmbeddedView(this.templateRef);
this.hasView = true;
} else if (condition && this.hasView) {
this.viewContainer.clear();
this.hasView = false;
}
}
This is gross. Instead of setting things up to somehow watch and react to changes in the condition, they wrote side effects directly in this appUnless setter function. I’m ok with a setter function emitting messages or something like that, but using it to directly flip state variables is bad. Let’s all try to avoid doing things that horrifying if Angular will let us do things some other, better way.
Also, from the example in the docs it’s not 100% clear to me how data from the attribute gets passed into the class. I mean, if you see this line *appUnless="condition"
in the template, how is the value of condition
getting passed in? Yes, I see that @Input() decorator, but is there some rule that establishes that there’s just one thing that gets the @Input() decorator in a directive class and that that is the thing that gets set to the value of condition? That seems plausible, but I’m not going down that boring path right now. [Update: I was wrong here, but you can read about the use of @Input in the docs.]
The most important thing in MobxAutorun seems to be this expression: autorun(() => view['detectChanges'](), { name: autorunName })
. The function autorun is straight from mobx itself, and autorunName is for some reason an executable string that has something to do with a detectChanges() function. Some of this is Angular and some of it is MobX. I want to check out the MobX autorun function deeper to see again what arguments it expects. Let’s read from here: https://mobx.js.org/refguide/autorun.html. The second argument is an options object, and attaching a “name” property appears to have no effect on functionality. It is a “string that is used as name for this reaction in for example spy
events. ” Then everything relevant here must come from view["detectChanges"]
.
It’s worth reminding ourselves at this point what happens if you simply remove this directive from the template in our mobx-computed-values example. Without the directive, all other things exactly the same, any time any change is made to the state everything is recomputed. It appears to still function correctly, but it does a lot of unnecessary computation.
Inside ngOnInit in this directive class, I see this.view = this. viewContainer. createEmbeddedView(this.templateRef)
. Why do we have to explicitly build the view? That seems like something that would happen automatically.
This article is worth reading:
https://medium.com/@mwhitt.w/going-crazy-with-structural-directives-part-1-demystifying-ngif-e01d42b2ffc1. It investigates the NgIf directive itself, copyright Google. The author of the article specifically mention the same objection I made earlier, that putting those side effects inside a setter function is gross—but he argues “in the end it is a clean solution”. I think by “clean” he means “it’s lazy but it works, so whatevs, yo.” That’s not my style.
I had to write and execute my own experiment to get a straight answer about explicitly executing this “createEmbeddedView” above. If you put a structural directive on something in your template, then you have to explicitly instantiate the view using code like this. viewContainer. createEmbeddedView(this.templateRef)
or else there will be nothing there. In my mobx-computed-values example you will see code where I create a new structural directive, MyTestDirective. It’s in the file my-test-directive.directive.ts. (Probably not a great name.) It does nothing except not instantiate the view.
Then in the root template I added <p *appMyTestDirective> Apply Test Directive on This</p>
. This element just doesn’t appear at all when we view the app. But if you add this. viewContainer. createEmbeddedView (this.templateRef)
inside the ngOnInit method, then it does appear. That code is already written in my file so you can just comment or un-comment it and see the difference. And that’s one way to figure something out when Google fails you.
Now that we understand the view a little better, lets look at MobxAutorun again, specifically inside the autorun function: autorun(() => view['detectChanges']() );
I omitted the second argument because it seems irrelevant. The view.detectChanges
function appears to look for changes in the state data associated to the view. According to the MobX doc here ( https://mobx.js.org/best/react.html), ”
MobX reacts to any existing observable property that is read during the execution of a tracked function.” So the autorun function will check for any observable properties that get read when detectChanges is executed, and it will re-run if any of those values change (or if they are accessed, or something like that).
As far as I can tell, this says that autorun will run if an observable changes, and, if it does, that will cause the view to update. That would seem to mean…nothing. If we don’t use some mobx contrivance like we’re doing here—if we just use vanilla Angular—and anything in the controller changes, the view updates itself. That seems to be how Angular works. What we’re trying to understand is how mobx-angular keeps the computed values from being re-computed unnecessarily if I alter an unrelated variable in the controller. That unrelated value gets updated in the view, but the computed values that don’t depend on it are not recomputed. If, however, I explicitly call view.detectChanges() myself outside of the autorun function, then everything gets recomputed. So just…being in the context of autorun somehow changes the way this built-in Angular function operates. And that is baffling to me.
I have investigated that question to the fullest extent of what I can do alone. I even copied a clone of mobx-angular’s MobxAutorun directive into my own project. I called it MyMobXTest. Referring to my own local test directive accomplishes the same goal as referring to the one from mobx-angular. It works just as the original does. Plus I could add some logging. But at this point, frankly, I have no idea how it makes any difference whether you execute view.detectChange() inside autorun or not. And it does make a difference. Why? My bafflement does not mean I have given up. It means I decided to contact the author Adam Klein directly and ask if he will lead me in the right direction.
For most of the vast ocean of programming knowledge out there, there is not already a blog post or a StackOverflow question that has been answered and then closed as off-topic. Sometimes you have to actually ask a person who knows more than you. I’ll let you know if Klein answers.
That’s a wrap on part 1 of Lesson 4 (this one). When I’ve made some progress on cracking this mystery, with or without help, I’ll write Part 2 and put a link to it here.
[Update: Part 2 with the answer to my above questions is here: https://adamcross.blog/2019/04/28/angular-lesson-4-part-2/. ]