mobxjs / mobx-angular

The MobX connector for Angular.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Combining multiple stores

yacut opened this issue · comments

The official documentation describes the usage of multiple stores, as well as the relationship between the parent store and the children store: https://mobx.js.org/best/store.html
Unfortunately, if you have an injectable root store and want to use it in all angular components, this concept does not work in angular, because it leads to a Circular dependency detected problem. So it is not possible to create a production build because aot does not allow that (showCircularDependencies setting doesn't help ).

Has anyone ever faced a problem like this? Maybe there is another solution besides to save in the children store a parent properties that you need.

cc @mweststrate @adamkleingit

related to #35

Just to understand, you have circular dependencies between the stores? So store 1 injects store 2 and store 2 injects store 1?
Can you share an example of a store hierarchy that would cause this problem?

@adamkleingit I made a small example based on a your todo example. UserStore has been added which has its own state (username, isLoggedIn, etc.) and the todoStore needs this state to allow or forbid some user actions:
https://codesandbox.io/embed/example-b2mvf

As I explained above, I'm just following the official documentation:

class RootStore {
  constructor() {
    this.userStore = new UserStore(this)
    this.todoStore = new TodoStore(this)
  }
}

class UserStore {
  constructor(rootStore) {
    this.rootStore = rootStore
  }

  getTodos(user) {
    // access todoStore through the root store
    return this.rootStore.todoStore.todos.filter(todo => todo.author === user)
  }
}

class TodoStore {
  @observable todos = []

  constructor(rootStore) {
    this.rootStore = rootStore
  }
}

I also added the "npm build" script which gives the following error:

$ ./node_modules/.bin/ng build --prod

Date: 2019-08-24T18:25:57.330Z
Hash: f20f704ec8adec5793f1
Time: 5614ms
chunk {0} runtime.ec2944dd8b20ec099bf3.js (runtime) 1.41 kB [entry] [rendered]
chunk {1} main.9868d9b237c3a48c54da.js (main) 128 bytes [initial] [rendered]
chunk {2} styles.0fc1d3e8b0f83340eb94.css (styles) 6.53 kB [initial] [rendered]

ERROR in : Can't resolve all parameters for AppComponent in ./example-2/src/app/app.component.ts: (?).

error Command failed with exit code 1.

@yacut

I don't think your implementation is good, I can see some weird stuff going on there. I didn't compile nor tested, but why don't you do some changes on your code?

I would say, don't pass this to both UserStore and TodoStore, you only need to pass the TodoStore to the UserStore. I think its cleaner. Try it out:

class RootStore {
  constructor() {
    this.userStore = new UserStore(TodoStore());
  }
}

class UserStore {
  constructor(todoStore: TodoStore) {
    this.todoStore = todoStore;
  }

  getTodos(user) {
    return this.todoStore.todos.filter(todo => todo.author === user);
  }
}

class TodoStore {
  @observable todos = [];
}

Something like might work for you.

Also, this is an issue tracker, for a question regarding implementation, you are better with StackOverflow.

Cheers!

@Avcajaraville Unfortunately, I can't agree with you. The implementation does not come from me, but is described in the official documentation. I only integrated it into the an Angular application with mobx-angular library. I also do not agree that this problem belongs to StackOverflow, because in my opinion it is either Bug or the limit of the mobx-angular library related to Angular itself. Therefore, it is necessary to either describe the limitation in README here or fix the bug.

@yacut
I am actually dealing as well with MobX and had more or less same issues, when I tried to apply the best-practices from MobX documentation. So @Avcajaraville I agree, this issues here are not made for an Q&A, but the input is for sure to take into your documentation. It would be nice to have a "translated" best-practices guide which matches for an Angular app. I had a lot troubles too to get a working app.

@yacut maybe a pattern I came up could help you too. So instead of using the constructor, create a second init-function, in with you pass the parameter. This way, Angular will not use the DI to create instances and then a loop.

todoStore: TodoStore;
  userStore: UserStore;


  constructor() {
    this.todoStore = new TodoStore();
    this.todoStore.init(this);
    this.userStore = new UserStore();
    this.userStore.init(this);
  }

export default class TodoStore {
  rootStore: RootStore;
  @observable todos = [];
  @observable filter = "SHOW_ALL";

constructor() {}

 init(rootStore: RootStore) {
    this.rootStore = rootStore;
    this.localStorageSync();
  }

export default class UserStore {
  rootStore: RootStore;
  @observable userName: string;
  @observable isLoggedIn: boolean;

constructor(){};

  init(rootStore: RootStore) {
    this.rootStore = rootStore;

    // fake state
    this.userName = "test user";
    this.isLoggedIn = true;
  }

`

I could not yet test it, but did something similar with a Store-Model relation.

@rekoch Thank you for sharing and understanding.
I think it can be used, but...
After after a few days of experimentation and research, I seem to have found a solution to this problem.

  1. We need to avoid Circular dependency detected warnings for the mobx store. To do this we need just to use any type in the child store constructor:
export default class TodoStore {
  rootStore: RootStore;
  @observable todos = [];
  @observable filter = "SHOW_ALL";

  constructor(rootStore: any) { // <-- avoiding warning
    this.rootStore = rootStore;
    this.localStorageSync();
  }
  // ...
}
  1. Angular DI is not able to automatically resolve all dependencies of our root store (it's either angular or mobx-angular library bug), so we need to specify exactly how to define and provide our root store. To do that, we need to create an InjectionToken for our rootStore and use it both when creating the RootStore provider and injecting it into a component:
// root.store.ts
import { Injectable, InjectionToken } from "@angular/core";
import TodoStore from "./todos.store";
import UserStore from "./user.store";

// <-- define injection token for our store
export const ROOT_STORE = new InjectionToken<string>("RootStore");

// https://mobx.js.org/best/store.html
@Injectable()
export default class RootStore {
  todoStore: TodoStore;
  userStore: UserStore;

  constructor() {
    this.todoStore = new TodoStore(this);
    this.userStore = new UserStore(this);
  }
}
// app.module.ts
import { BrowserModule } from "@angular/platform-browser";
import { NgModule } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { HttpModule } from "@angular/http";
import { MobxAngularModule } from "mobx-angular";
import { AppComponent } from "./app.component";
import { SectionComponent } from "./components/section/section.component";
import { FooterComponent } from "./components/footer/footer.component";
import { CountComponent } from "./components/count/count.component";
import RootStore, { ROOT_STORE } from "./stores/root.store";

export function rootStoreFactory(http: HttpModule): RootStore {
  // http module can be only used in root store
  return new RootStore();
}

@NgModule({
  declarations: [
    AppComponent,
    SectionComponent,
    FooterComponent,
    CountComponent
  ],
  imports: [BrowserModule, FormsModule, HttpModule, MobxAngularModule],
  providers: [
    {
      // <-- use InjectionToken to provide the RootStore
      provide: ROOT_STORE,
      useFactory: rootStoreFactory,
      deps: [HttpModule]
    }
  ],
  bootstrap: [AppComponent]
})
export class AppModule {}
// count.component.ts
import { ChangeDetectionStrategy, Component, Inject } from "@angular/core";
import RootStore, { ROOT_STORE } from "../../stores/root.store";

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: "app-count",
  template: `
    <span id="todo-count" *mobxAutorun>
      <strong>{{ rootStore.todoStore.uncompletedItems }}</strong> items left
    </span>
  `
})
export class CountComponent {
  // <-- inject RootStore via token
  constructor(@Inject(ROOT_STORE) public rootStore: RootStore) {}
}

P.S. I hope to see this example in the documentation of this project

Injecting RootStore is an antipattern, no difference from the ServiceLocator pattern: https://blog.ploeh.dk/2010/02/03/ServiceLocatorisanAnti-Pattern/

How do you suppose to write a unit test for a store that uses the root store? How do you know which parts of the store you need to mock?

Complete agree with @Avcajaraville - inject only those stores that are required for a specific store.

I would do this: not pass anything to the stores in the c'tor, but initialize rootStore reference inside the rootStore c'tor. For example:

@Injectable()
class RootStore {
  constructor(private userStore: UserStore, private todosStore: TodosStore) {
    userStore.rootStore = this;
    todosStore.rootStore = this;
  }
}

@Injectable()
class UserStore {
  public rootStore: RootStore; // Don't set this property outside RootStore c'tor
  constructor() {
    // Here you can't rely on having rootStore initialized
  }
}

@Injectable()
class TodosStore {
  public rootStore: RootStore; // Don't set this property outside RootStore c'tor
  constructor() {
    // Here you can't rely on having rootStore initialized
  }
}

Then in the components you can inject whatever store you want regularly