Search code examples
angulartypescriptstatengrx

In NgRx How Can I Retain Type<any> Throughout My App State While Following Best Practices?


Using Angular 11.1.2 and rxjs 6.6.2

I would like to produce an app which displays a list of components dynamically. I have achieved this piece independently. The issues I am now facing revolve around transitioning to state management via NgRx.

I am using the following model:

import { Type } from '@angular/core';

export enum Position { Default, Top, Bottom }

export class NodeModel {
  constructor(
    public type: Type<any>,
    public position: Position
  ) {}
}

Which is fed through a series of *ngFor and @Input()s to be used in a service to generate dynamic components:

@Component({
  selector: 'app-node-body',
  template: '<ng-container #container></ng-container>',
  styleUrls: ['./node-body.component.scss']
})
export class NodeBodyComponent implements AfterViewInit {
  @ViewChild('container', { read: ViewContainerRef, static: false }) viewRef;
  @Input() type: Type<any>;
  
  constructor(private nodeBodyService: NodeBodyService) { }
  
  ngAfterViewInit(): void {
    this.nodeBodyService.createDynamicComponent(this.type, this.viewRef);
  }
}
@Injectable()
export class NodeBodyService {
  constructor(private cfr: ComponentFactoryResolver) {}
  
  createDynamicComponent<T>(component: Type<T>, viewRef: ViewContainerRef): ComponentRef<any> {
    const factory = this.cfr.resolveComponentFactory<T>(component);
    return viewRef.createComponent(factory);
  }
}

At this point the app produces the dynamic components correctly.
I am facing issues when switching my approach to using NgRx to start monitoring the state of my application. The following approach does not allow me to see the Type<any> value neither in the state, nor in the respective body.component.ts and thus the dynamic components fail to render.

// node-list.actions.ts
export const SET_NODES = '[Node List] Set Nodes';

export class SetNodes implements Action {
  readonly type = SET_NODES;
  constructor(public payload: NodeModel[]) {}
}

export type NodeListActions = SetNodes;
// node-list.reducer.ts
export interface State {
  nodes: NodeModel[];
}

export function nodeListReducer(
  state: State,
  action: NodeListActions.NodeListActions
) {
  switch (action.type) {
    case NodeListActions.SET_NODES:
      return {
        ...state,
        nodes: action.payload
      };
    default:
      return state;
  }
}
import * as fromApp from './store/app.reducer';
import * as NodeListActions from './node-list/store/node-list.actions';

@Component({
  selector: 'app-root',
  template: '<app-node-list [nodes]="nodes"></app-node-list>',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
  nodes: NodeModel[] = [
    new NodeModel(SuperHeaderComponent, Position.Default),
    new NodeModel(IntroductionComponent, Position.Bottom)
  ];
  
  constructor(private store: Store<fromApp.AppState>) {}
  
  ngOnInit(): void {
    this.store.dispatch(new NodeListActions.SetNodes(this.nodes));
  }
}

This is what shows up after the SET_NODES actions is dispatched:

{
  nodeList: {
    nodes: [
      {
        position: 0
      },
      {
        position: 2
      }
    ]
  }
}

I recognize using Type<any> at runtime yields { position: 0, type: Function } which is not "state-like." So I went the route of trying a type map such as Map<string, Type<any>> along with changing my NodeModel

import { Type } from '@angular/core';

export enum Position { Default, Top, Bottom }

export class NodeModel {
  constructor(
    public type: string, // <-- Switched this so that it is visible in state
    public position: Position
  ) {}
}

This DID resolve my issue with respect to now displaying what I want to see in my app state, but I was still not able to render my dynamic components as the following error shows up:

ERROR TypeError: Cannot add property __NG_ELEMENT_ID__, object is not extensible

From what I have read so far it appears I could turn this error off if I wish but would not be best practice.

Am I approaching state management correctly by attempting to store Type<any> or even the idea of referencing the type to begin with?
If this is not appropriate state management, how might I monitor dynamic components and their respective state; for when I resolve this issue I would seek to have state communicated from the dynamic components back up to the root?


Solution

  • I managed to resolve my issue without sacrificing state immutability. Where I ended up going wrong was assuming I had not stored Type<any> in my state object.

    Type<any> was stored in my app state, but due to it being a Function I could not see it in the redux devtools. I resorted to adjusting my state object to the following:

    export interface Node {
      type: string;
      position: Position;
    }
    

    I then updated my service to use a type map:

    @Injectable()
    export class NodeBodyService {
      
      constructor(private cfr: ComponentFactoryResolver) {}
      
      nodeTypes: Map<string, Type<any>> = new Map([
        [SuperHeaderComponent.name, SuperHeaderComponent],
        [IntroductionComponent.name, IntroductionComponent]
      ]);
      
      createDynamicComponent(component: string, viewRef: ViewContainerRef): void {
        const nodeType: Type<any> = this.nodeTypes.get(component);
        viewRef.createComponent(this.cfr.resolveComponentFactory(nodeType));
      }
    }
    

    I am now able to see my dynamic components rendered, and the appropriate state