ezzabuzaid / react-context-in-angular

1 stars 1 forks source link

Angular implementation #12

Open ezzabuzaid opened 3 years ago

ezzabuzaid commented 3 years ago

In Angular things are different, so we'll do things in different styles but the same concept and goals remain.

If you start this article from the beginning you saw that we introduced three components

  1. context
  2. provider
  3. consumer

and ended up using them like this

@Component({
  selector: 'app-root',
  template: `
    <context name="FamilyContext"> // (1) -----> The Context Component
      <provider name="FamilyContext" [value]="familyNameValue"> // (2) -----> The Provider Component
        <app-parent> </app-parent>
      </provider>
    </context>
`
})
export class AppComponent { }

@Component({
  selector: 'app-grandchild',
  template: `
    <consumer name="FamilyContext"> // (3) -----> The Consumer Component
        <ng-template let-value>
           Family Name: {{value}}
        </ng-template>
    </consumer>
  `
})
export class GrandchildComponent { }

I'll explain each component in detail soon.

Utility function for strict mode people 😅

export function assertNotNullOrUndefined<T>(value: T, debugLabel: string): asserts value is NonNullable<T> {
    if (value === null || value === undefined) {
        throw new Error(`${ debugLabel } is undefined or null.`);
    }
}

export function assertStringIsNotEmpty(value: any, debugLabel: string): asserts value is string {
    if (typeof value !== 'string') {
        throw new Error(`${ debugLabel } is not string`);
    }
    if (value.trim() === '') {
        throw new Error(`${ debugLabel } cannot be empty`);
    }
}

The Context Component

This component is responsible for declaring a scope for providers and consumers, providers can only be under their context, the same rule applies to consumers.

Unlike React Context API, we don't have reference to a context so in order to ensure the relationship between providers and consumers to a context we need to give the context and its components a name.

A name makes it possible to

  1. Have multiple contexts that can be used without interfering with each other.
  2. The provider and consumer to find their Context easily by looking the name up.
  3. Ensures a provider and a consumer are defined under their context and not in any other place.
  4. Prevents duplicated contexts.

Another thing related to the context component is the defaultValue, if you recall from above if a context doesn't have any provider a default value will be used instead.

ContextDefaultValue

In the previous Image, Consumer ( A ) will have the value of the Context because there's no provider above it, and Consumer ( B ) will have the value of Provider ( 1 ).

Initial Implementation

@Component({
  selector: 'context',
  template: '<ng-content></ng-content>' // ----> (1)
})
export class ContextComponent implements OnInit, OnChanges {
  @Input() name!: string; // ----> (2)
  @Input() defaultValue?: any; // ----> (3)

  constructor() { }

  ngOnInit(): void {
    assertStringIsNotEmpty(this.name, 'Context name');  // ----> (4)
  }

  ngOnChanges(changes: SimpleChanges): void {
    const nameChange = changes.name;
    if (nameChange && !nameChange.isFirstChange()) {
      const { currentValue, previousValue } = nameChange;
      throw new Error(`Context name can be initialized only once.\n Original name ${ previousValue }\n New name ${ currentValue }`);
    }
  }

}
  1. ng-content to project the content as is.
  2. Name of the context. reasons above 😁
  3. value that will be provided to the consuming components in case there's no provider for this context.
  4. Ensures the context name is a string and not empty. The same check will be used in the other components.
  5. The name cannot be changed since the code should adhere to the React approach, nevertheless, this is totally up to you. the same check will be used in the other components.

The Provider Component

This component will pass down its value to the consumers hence we need to have an input for that value. Also, you can have zero or more provider components for the same context. consumers will get the value from the nearest one.

ProvidersChain

In the previous Image, Consumer ( A ) will have the value of the Context, but Consumer ( B ), Consumer ( C ), and Consumer ( E ) will have the value of Provider ( 1 ). Consumer ( D ) will have the value of Provider ( 2 ) because it is the nearest one.

Initial Implementation

@Component({
  selector: 'provider',
  template: '<ng-content></ng-content>'
})
export class ProviderComponent implements OnInit {
  @Input() name!: string;   // ----> (1)
  @Input() value?: any;   // ----> (2)

  ngOnInit(): void {
    assertStringIsNotEmpty(this.name, 'Provider context name');

    if (this.value === undefined) {   // ----> (3)
      throw new Error(`Provider without value is worthless.`);
    }
  }

  ngOnChanges(changes: SimpleChanges): void {
    const nameChange = changes.name;
    if (nameChange && !nameChange.isFirstChange()) {
      const { currentValue, previousValue } = nameChange;
      throw new Error(`Context name can be initialized only once.\n Original name ${ previousValue }\n New name ${ currentValue }`);
    }
  }

}
  1. Name of the context. The name is needed in order to know to which context it belongs.
  2. value that will be provided to the consuming components.
  3. The provider is valuable as long it holds a value, if at first it doesn't so there's no point in having it, let the consumers rely on a different provider or the default value provided when establishing the context

The Consumer Component

The component will eventually have the value of the nearest provider or the default context value in case no provider is found up in the tree.

before digging into it, let's see the example usage first.

@Component({
  selector: 'app-grandchild',
  template: `
    <consumer name="FamilyContext">
        <ng-template let-value>
           Family Name: {{value}}
        </ng-template>
    </consumer>
`
})
export class GrandchildComponent { }

ng-template will be used as a convenient way to be able to provide the nearest provider value or the context defaultValue using template variable let-value and to have more control over the change detection process. More about this later on.

Initial Implementation

@Component({
  selector: 'consumer',
  template: '<ng-content></ng-content>',
})
export class ConsumerComponent implements OnInit {
  @Input() name!: string;   // ----> (1)
  @ContentChild(TemplateRef, { static: true }) templateRef!: TemplateRef<any>;   // ----> (2)

  ngOnInit(): void {
    assertStringIsNotEmpty(this.name, 'Consumer context name');

    if (this.templateRef === undefined) {   // ----> (3)
      throw new Error(`
        Cannot find <ng-template>, you may forget to put the content in <ng-template>.
        If you do not want to put the content in context then no point in using it.
      `);
    }
  }

  ngOnChanges(changes: SimpleChanges): void {
    const nameChange = changes.name;
    if (nameChange && !nameChange.isFirstChange()) {
      const { currentValue, previousValue } = nameChange;
      throw new Error(`Context name can be initialized only once.\n Original name ${ previousValue }\n New name ${ currentValue }`);
    }
  }

}
  1. Name of the context. The name is needed in order to know to which context it belongs.
  2. The template reference, static: true used to be able to get it in ngOnInit.
  3. ng-template is mandatory. why would you need to use the consumer if you're not making use of it is value?

RECAP: all the code right now only validates the inputs.

The next step is to make sure providers and consumers components are using the correct context.


Hopefully, You know Dependency Injection and how the resolution process works. in nutshell, You inject a dependency and Angular will search for the implementation in several injectors if none is found an error will be all over the browser console 😁.

It is important to understand the resolution process in order to understand the rest of the code. the validation and value resolving logic relying on that mechanism. basically, we'll link each component type with the immediate next one above it, it is like creating a chain of components each has its parent and the final one (first on the tree) will have null. just like the Prototype Chain 😁. take a look at the next image, perhaps it will clear the idea.

ResolutionProccess

Context Validation

  1. Context should be unique, you cannot have multiple contexts with the same name.
  2. Providers and Consumers must have a context.

First, adding a method to ContextComponent that will ensure no other context with the same name is exist.

@Component({
  selector: 'context',
  template: '<ng-content></ng-content>',
})
export class ContextComponent implements OnInit {
  @Input() defaultValue?: any;
  @Input() name!: string;

  constructor(
    @Optional() @SkipSelf() public parentContext: ContextComponent | null   // ----> (1)
  ) { }

  ngOnInit(): void {    
    assertStringIsNotEmpty(this.name, 'Context name');  // ----> (2)
    this.ensureContextUniqueness(this.name);
  }

  ... code omitted for brevity

  public getContext(contextName: string) {  // ----> (3)
    let context: ContextComponent | null = this;
    while (context !== null) {
      if (context.name === contextName) {
        return context;
      }
      context = context.parentContext;
    }
  return undefined;
  }

  public ensureContextUniqueness(contextName: string) {   // ----> (4)
    let context: ContextComponent | null = this.parentContext;
    while (context !== null) {
      if (context.name === contextName) {
        throw new Error(`Context ${ this.name } already exist.`);
      }
      context = context.parentContext;
    }
  }

}
  1. Inject the parent context component 😲 Check the previous image.

    @Optional() is used to implies that this context may be the first context in the tree, therefore no parents will be found. @SkipSelf() is used to tell the dependency resolution to skip the current component injector and start the process from the parent injector because we already have the current context.

  2. Checks if a context with the same name already exists and if so throws an error.

  3. Find a context by a name, starting from the current context, check if its name is equal to the parameter, if not equal repeat the same step with the parent. In the end, if no context is found return undefined. This method will be needed later on with the other components.

  4. Same steps as three but start with the parent context and not the context itself.

Second, modify the ProviderComponent to grab its context and ensure that it exists.

@Component({
  selector: 'provider',
  template: '<ng-content></ng-content>'
})
export class ProviderComponent implements OnInit {
  @Input() name!: string;
  @Input() value?: any;
  private providerContext!: ContextComponent;

  constructor(
    @Optional() private context: ContextComponent | null,    // ----> (1)
  ) { }

  ngOnInit(): void {
    ... code omitted for brevity

    if (this.context === null) {    // ----> (2)
      throw new Error(
        'Non of provider ancestors is a context component,
         ensure you are using the provider as a context descendant.'
      );
    }

    this.providerContext = this.context.getContext(this.name);  // ----> (3)
    assertNotNullOrUndefined(this.providerContext, `Provider context ${this.name}`);  // ----> (4)
  }

  public getProvider(contextName: string) {  // ----> (5)
    let provider: ProviderComponent | null = this;
    while (provider !== null) {
      if (provider.name === contextName) {
        return provider;
      }
      provider = provider.parentProvider;
    }
    return undefined;
  }

}
  1. Inject the ContextComponent. Angular will search for the nearest context component and inject it, this component will be used to search for another context up in the tree.
  2. Check if there's context at all before searching for the provider context. this might be helpful so you immediately know you missed adding the context.
  3. Get the provider context and assign it to its instance.
  4. Ensure the provider has context.
  5. Find a provider by a context name, starting from the current provider, check if its name is equal to the parameter, if not equal repeat the same step with the parent. In the end, if no provider is found it is okay to return undefined to state that a context doesn't have a provider since it's optional. This method will be needed soon in the consumer component.

Third, modify the ConsumerComponent to grab its context and provider and ensure its context is exists.

@Component({
  selector: 'consumer',
  template: '<ng-content></ng-content>',
})
export class ConsumerComponent implements OnInit {
  @Input() name!: string; 
  @ContentChild(TemplateRef, { static: true }) templateRef!: TemplateRef<any>;
  private consumerContext!: ContextComponent;
  private consumerProvider?: ProviderComponent;

  constructor(
    @Optional() private context: ContextComponent  // ----> (1)
  ) { }

  ngOnInit(): void {
    ... code omitted for brevity

    if (this.context === null) {   // ----> (2)
      throw new Error(
        'Non of consumer ancestors is a context component,
         ensure you are using the consumer as a context descendant.'
      );
    }
    this.consumerContext = this.context.getContext(this.name);  // ----> (3)
    this.consumerProvider = this.provider?.getProvider?.(this.name);  // ----> (4)
    assertNotNullOrUndefined(this.consumerContext, `Consumer context ${this.name}`);  // ----> (5)
  }
}
  1. Inject the ContextComponent. Angular will search for the nearest context and inject it.
  2. Check if there's context at all before searching for the consumer context. this might be helpful so you immediately know you missed adding the context.
  3. Get the consumer context and assign it to its instance.
  4. Ensure the consumer has a context.
  5. Get the consumer nearest provider and assign it to the consumer instance. This will be used next to observe provider value changes.

RECAP: The code validates the inputs and ensures a context exists and only one exists and is correctly used, also it guides the developer on how to use the context and its components.

Now, it's time for getting the value from the context and the nearest provider to the consumer.

Providing the value

If you start this article from the beginning you've read that

The Consumer will re-render whenever a Provider ancestor value changes.

That means the ng-template should be updated as well and not just building it the first time.

Providing the value might seem easy at first glance since you just need to build the ng-template and bind a value to it, while that is correct, there're other concerns when it comes to Angular Change Detection, for instance updating the template value in a component that is using OnPush change detection strategy is difficult than the normal component that uses the Default change detection strategy, more information about this soon in sepearted section.

For building, there's ViewContainerRef the create and host the ng-template, also it returns a reference to the ng-template so we can use it to update its value. more examples and information.

@Component({
  selector: 'consumer',
  template: '<ng-content></ng-content>',
})
export class ConsumerComponent implements OnInit, OnDestroy {
  ... code omitted for brevity

  private buildTemplate(initialValue: any) {   // ----> (1)
    this.embeddedView = this.viewContainerRef.createEmbeddedView(this.templateRef, {
      $implicit: initialValue
    });
  }

  private updateTemplate(newValue: string) {   // ----> (2)
    this.embeddedView!.context = {
      $implicit: newValue
    };
    this.embeddedView?.markForCheck();
  }

  private render(value: any) {   // ----> (3)
    if (this.embeddedView) {
      this.updateTemplate(value);
    } else {
      this.buildTemplate(value);
    }
  }

}
  1. Create the template passing it the initial value (which could be its context default value or its nearest provider current value) and stores the ng-template reference for later use.
  2. Update the template value, the let-value, and mark it to be checked in the next change detection cycle.
  3. Wrapper method to either update the template in case it is already there or build it otherwise.

For value changes, normally, the lifecycle that is used to observe @Input changes is OnChanges, but since the value is not passed directly to the consumer component it cannot be used there.

The ProviderComponent will have the ReplaySubject that will emit the new provider value and the ConsumerComponent will subscribe to that subject to update its template.


@Component({
  selector: 'provider',
  template: '<ng-content></ng-content>'
})
export class ProviderComponent implements OnInit, OnDestroy {
  private valueState = new ReplaySubject<any>(1);   // ----> (1)

  ngOnChanges(changes: SimpleChanges): void {   // ----> (2)
    const valueChange = changes.value;
    if (valueChange) {
      this.brodcaseValueChanges(valueChange.currentValue);
    }
  }

  ... code omitted for brevity

  private brodcaseValueChanges(newValue: any) {
    this.valueState.next(newValue);
  }

  public valueChanges() {   // ----> (3)
    return this.valueState.asObservable();
  }

  ngOnDestroy(): void {
    this.valueState.complete();   // ----> (4)
  }

}
  1. Initialize the ReplaySubject with a buffer up to 1 so the new consumers will always be able to access the provider's last value.
  2. Modify the ngOnChanges lifecycle that was used before to ensure the context name doesn't change to have the logic of detecting the provider value changes.
  3. Convert the ReplaySubject to observable for the consumers' components.
  4. On ProviderComponent destroy, complete the ReplaySubject to free up the memory.

Now with the ConsumerComponent part


@Component({
  selector: 'consumer',
  template: '<ng-content></ng-content>',
})
export class ConsumerComponent implements OnInit, OnDestroy {

  private providerValueChangesSubscription?: Subscription;  // ----> (1)

  ngOnInit(): void {
    if (this.consumerProvider) {  // ----> (2)
      this.providerValueChangesSubscription = this.consumerProvider
        .valueChanges()
        .subscribe((providerValue) => {
          this.render(providerValue);  // ----> (3)
        });
    } else {  // ----> (4)
      this.render(this.consumerContext.defaultValue);
    }
  }

  ... code omitted for brevity

  ngOnDestroy(): void {
    this.providerValueChangesSubscription?.unsubscribe();  // ----> (5)
  }

}
  1. A field to hold the provider subscription to unsubscribe on the component destroy.
  2. Check if a provider is defined to subscribe to its value changes.
  3. If there's a provider re-render on its value changes
  4. If there's no provider render only once with the context default value.
  5. Unsubscribe from the provider ReplaySubject on component destroy.

Well, you made it so far, good for you! 😄✌️, now you have React Context in Angular, how great was that? Let's see the Angular way of sharing data in the component tree.