DEV Community

ezzabuzaid for This is Angular

Posted on

React Context In Angular

In this article, I'm going to show you how to mimic React Context API in Angular, I'll start by defining React Context, talk about what problem is meant to solve, and a possible implementation in Angular.

I will focus more on implementation and detail it as possible rather than explaining definitions, nonetheless, I'll make sure to explain any irrelevant terms.

If at any point you don't feel interested in reading further, think of this article as a new approach for component communication in Angular.

For clarity about what I’m going to talk about, the project is available to browse through Github. or a Demo if you prefer.

What Is React Context API

From React Documentation

Context provides a way to pass data through the component tree without having to pass props down manually at every level.

props in Angular terms corresponds to Inputs

In other words, context can help you to pass down inputs/props through a components tree without the need to define them at every level/component.

Words πŸ“ might not be that efficient, a practical example might be.

The Problem

Here's 4 components (AppComponent, Parent, Child, Grandchild), the AppComponent passes a value to the Parent component, the Parent component will pass it to the Child component which forwards it to the Grandchild component.

@Component({
  selector: 'app-root',
  template: '<app-parent [familyName]="familyNameValue"></app-parent>'
})
export class AppComponent {
  familyNameValue = 'The Angulars';
}
Enter fullscreen mode Exit fullscreen mode
@Component({
  selector: 'app-parent',
  template: '<app-child [familyName]="familyName"></app-child>'
})
export class ParentComponent {
  @Input() familyName: string;
}
Enter fullscreen mode Exit fullscreen mode
@Component({
  selector: 'app-child',
  template: '<app-grandchild [familyName]="familyName"></app-grandchild>'
})
export class ChildComponent {
  @Input() familyName: string;
}
Enter fullscreen mode Exit fullscreen mode
@Component({
  selector: 'app-grandchild',
  template: 'Family Name: {{familyName}}'
})
export class GrandchildComponent {
  @Input() familyName: string;
}
Enter fullscreen mode Exit fullscreen mode

As you see, we had to declare the same input at every component starting from the Parent down the Grandchild, in React terms this is called Prop Drilling.

Going to the definition again

Context provides a way to pass data through the component tree without having to pass props Inputs down manually at every level.

Good, let's see the Context way.

The Solution

Hint: I'll explain the implementation later on. keep reading for now.

What if you can remove the inputs and only have a generic one that could be accessed from anywhere in the tree, like this

@Component({
  selector: 'app-root',
  template: `
    <context name="FamilyContext">
      <provider name="FamilyContext" [value]="familyNameValue"> // This part
        <app-grandchild> </app-grandchild>
      </provider>
    </context>
`
})
export class AppComponent { }
Enter fullscreen mode Exit fullscreen mode

And for the component that needs the value

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

ContextVsNoContext

While this approach seems to work, I do not think a lot of people will agree on this, I myself thought about sandboxing first, maybe that's why there's no like to React Context API in Angular. but again see it as a different way to achieve the same result.

By now it's clear what problem does Context API solves. It's time to see how it works.

How Does React Context API Work

Warning: I'll use React components 😏.

Context API comes with two important components, Provider and Consumer. Provider is the component that will pass a value for decedents consuming components. One provider can have multiple consumers and other providers.

Consumer, as you may have thought, will consume Provider value. React will go up the component tree starting from the Consumer component to find the nearest Provider and provide its value to that Consumer as callback style, if none is found a default value will be used instead. The Consumer will re-render whenever a Provider ancestor value changes.

To create context you simply call createContext passing default value if needed, a context object with Provider and Consumer components attached to it will return.

const MyContext = React.createContext('defaultValue');
Enter fullscreen mode Exit fullscreen mode

The provider have value props that will passed down to the consumers.

function App() {
  return (
    <MyContext.Provider value="valueToBeConsumedByDescendantsConsumer">
      <ComponentThatHaveConsumerAsChild />
    </MyContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

The consumer takes a function with the Provider value as an argument, the function will be called (re-render πŸ™ƒ) whenever the Provider value changes.

function ComponentThatHaveConsumerAsChild() {
  return (
    <MyContext.Consumer>
      {(value) => (<h1>{value}</h1>)}
    </MyContext.Consumer>
  );
}
Enter fullscreen mode Exit fullscreen mode

You might want to know that this is not the only way to consume context, there's contextType and useContext, I won't cover them because those are applicable only to React way of doing things.

if you didn't get the whole picture, check the official docs, perhaps it would be more helpful.

Enough talking about React. It's time to code.

Angular implementation

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 { }
Enter fullscreen mode Exit fullscreen mode

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`);
    }
}
Enter fullscreen mode Exit fullscreen mode

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 }`);
    }
  }

}
Enter fullscreen mode Exit fullscreen mode
  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 }`);
    }
  }

}
Enter fullscreen mode Exit fullscreen mode
  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 { }
Enter fullscreen mode Exit fullscreen mode

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 }`);
    }
  }

}
Enter fullscreen mode Exit fullscreen mode
  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'); 
    this.ensureContextUniqueness(this.name); // ----> (2)
  }

  ... 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;
    }
  }

}
Enter fullscreen mode Exit fullscreen mode
  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.

  1. Checks if a context with the same name already exists and if so throws an error.
  2. 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.
  3. Like point 3, 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;
  }

}
Enter fullscreen mode Exit fullscreen mode
  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)
  }
}
Enter fullscreen mode Exit fullscreen mode
  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);
    }
  }

}

Enter fullscreen mode Exit fullscreen mode
  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)
  }

}

Enter fullscreen mode Exit fullscreen mode
  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)
  }

}

Enter fullscreen mode Exit fullscreen mode
  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.

The Angular Way

Angular does have Dependency Injection framework that provides different approaches to handle a situation where something like React Context API is needed.

In the "The Problem" section, you saw that to pass down a value to the descendants' components you have to declare an @Input at every component even though that a component might merely act as a wrapper for another component. This actually can be changed by providing an InjectionToken to the ancestor component and inject that token at any descendant component to utilize the value.

Change the root component to include the InjectionToken

const FamilyNameToken = new InjectionToken('FamilyName');
@Component({
  selector: 'app-root',
  template: `<app-grandchild> </app-grandchild>`,
  providers: [{provide: FamilyNameToken, useValue: 'The Angulars'}]
})
export class AppComponent { }
Enter fullscreen mode Exit fullscreen mode

And for the component that needs the value to inject the InjectionToken

@Component({
  selector: 'app-grandchild',
  template: `Family Name: {{familyNameValue}}`
})
export class GrandchildComponent {
  constructor(@Inject(FamilyNameToken) public familyNameValue: string) { }
 }
Enter fullscreen mode Exit fullscreen mode

That might look easy and simple at first, but the catch is when you want to update the value you need to have a kind of RxJS Subject because Angular will hard inject the value that corresponds to the InjectionToken into the GrandchildComponent. The other way is to use a class provider to act as a state holder.

class FamilyName {
  private state = new ReplaySubject(1);
  public setName(value: string) {
    this.state.next(value);
   }
  public getName() {
    return this.state.asObservable();
  }
}
Enter fullscreen mode Exit fullscreen mode

The root component will inject the class and sets the value.

@Component({
  selector: 'app-root',
  template: `<app-grandchild> </app-grandchild>`,
  providers: [FamilyName]
})
export class AppComponent {
  constructor(public familyName: FamilyName) {
    $familyNameState = this.familyName.setName('The Angulars');
  }
}
Enter fullscreen mode Exit fullscreen mode

And for the component that needs the value to inject the FamilyName class and subscribe to the changes.

@Component({
  selector: 'app-grandchild',
  template: `Family Name: {{$familyNameState|async}}`
})
export class GrandchildComponent {
  $familyNameState = this.familyName.getName();
  constructor(public familyName: FamilyName) { }
 }
Enter fullscreen mode Exit fullscreen mode

Also, you can re-provide the FamilyName class at any component level so it can act as the ProviderComponent.

With that said, having a way to pass down a value within the component template it self can reduce the amount of class you will need.

Example

To put the implementation in action, I'll use chat components to illustrate the usage of the context.

Follow the demo to see the result.

Chat Message Component
Uses consumer to obtain the message

@Component({
    selector: 'app-chat-message',
    template: `
    <consumer name="ChatContext">
        <ng-template let-value>
            <h4>{{value.message}}</h4>
        </ng-template>
    </consumer>
    `
})
export class ChatMessageComponent { }
Enter fullscreen mode Exit fullscreen mode

Chat Avatar Component
Uses consumer to obtain the avatar. notice the changeDetection is changed to OnPush.

@Component({
    selector: 'app-chat-avatar',
    changeDetection: ChangeDetectionStrategy.OnPush,
    template: `
    <consumer name="ChatContext">
        <ng-template let-value>
            <img width="50" [src]="value.avatar">
        </ng-template>
    </consumer>
    `
})
export class ColorAvatarComponent { }
Enter fullscreen mode Exit fullscreen mode

Chat Container Component
Group the other components and perhaps for styling and aligning. it uses the provider declared in AppComponent for the first chat message and a new provider for the second chat message

@Component({
    selector: 'app-chat-container',
    template: `
    <div style="display: flex;">
        <app-chat-avatar></app-chat-avatar>
        <app-chat-message></app-chat-message> 
        <provider name="ChatContext" [value]="{name:'Nested Provider Value'}">
            <app-chat-message></app-chat-message>
        </provider>
    </div>
    `
})
export class ChatContainerComponent { }
Enter fullscreen mode Exit fullscreen mode

App Component
Declare a context with the name ChatContext with no default value and a provider with initial value chatItem which will be shared to ChatMessageComponent and ChatAvatarComponent.

Clicking on the Change Chat Item button will update the chatItem reference hence updating the consumers to get the new value.

@Component({
  selector: 'app-root',
  template: `
  <context name="ChatContext">
    <provider [value]="chatItem" name="ChatContext">
      <app-chat-container></app-chat-container>
    </provider>
  </context>
  <button (click)="updateChatItem()">Change Chat Item</button>
  `
})
export class AppComponent {
  chatItem = {
    message: 'Initial name',
    avatar: 'https://icon-library.com/images/avatar-icon-images/avatar-icon-images-4.jpg',
  }

  updateChatItem() {
    const randomInt = Math.round(Math.random() * 10);
    this.chatItem = {
      message: `Random ${ randomInt }`,
      avatar: `https://icon-library.com/images/avatar-icon-images/avatar-icon-images-${ randomInt }.jpg`,
    }
  }

}
Enter fullscreen mode Exit fullscreen mode

Bonus Part: The Issue With OnPush

In the Angular Implementation section, there's was an issue when a consumer host component (the component that will be the consumer parent) is using the OnPush change detection strategy so as fix a ReplaySubject used to share the value to the consumer component from its nearest provider.

The thing is that OnPush prevents the component from being auto-checked thus the template of the component won't be updated except on special cases.

  • One of the components @Input reference changed.
  • An event handler of the component was triggered.
  • An observable linked to the component template via the async pipe emits.

Unfortunately, neither of the cases above is applicable on The ConsumerComponent

  1. It doesn't have an @Input for the value because it will be bonded indirectly.
  2. It doesn't have any event handler.
  3. And no observable can be linked to its template since we project the content as is.

Hint: component template implies the template property in the @Component decorator and doesn't refer to ng-template.

The other solution and the initial implementation was to use The DoCheck lifecycle because it is usually used when a component is using OnPush change detection strategy to detect changes to mutable data structures and mark the component for the next change detection check cycle accordingly.

Moreover, the DoCheck lifecycle will be invoked during every change detection run but with OnPush the change detector will ignore the component so it won't be invoked unless it happens manually and again even this is out of the scope because you do not know if the consumer provider value changed or not.

That was just a plus section for the folks who will wonder about that.

Summary

If you didn't use state management libraries before, you might find this handy since it somehow solves the same issue, and if you coming from React background this can be an advantage to have in Angular, nevertheless, Angular can do it on its own with a bit knowledge of dependency injection.

Having such functionality in your app can impart additional value, on the other hand, you have to adapt to the new way of sharing data.

The drawing was made via Excalidraw.

Discussion (11)

Collapse
technbuzz profile image
Samiullah Khan

I kinda lost my way while going through angular implementation πŸ˜€, otherwise the approach is really good.

In addition I was involved in property drilling in one of the project, but for simple case I didn't wanted to subjects or state management library. I added one route in between and that cleared out a lot of thinks. I can't keep a lot things in my head if data goes so deep in the dependency tree.

Collapse
ezzabuzaid profile image
ezzabuzaid Author

Sorry about that, I'll go over the implementation again and try to enhance it.

Yes, when data starting to get pretty down, it's a sign that the design may not be as good.

Collapse
chaos_monster profile image
little chaosmonster

Why not use scoped Services as intended by the framework. This approach seems overly complicated

Collapse
ezzabuzaid profile image
ezzabuzaid Author

It's totally fine and the preferred way to use services, of course, nonetheless, using context can reduce an necessary code when you just want to pass input to descendant components.

Collapse
chaos_monster profile image
little chaosmonster

How does it reduce code? I don't see that argument. because a simple service that uses only public properties and be provided and injected isn't more code than this approach AND it stays in the pattern suggested by the framework.

Collapse
knightsarai profile image
knightsarai • Edited

Such a great article!

Our team still uses AngualrJS, so I couldn't help it to stop thinking about how this approach can be transfered to AngualrJS, I'm going to give it a try and share the results with you.

Collapse
ezzabuzaid profile image
ezzabuzaid Author

Can't wait for the feedback!

Collapse
waelghaith profile image
Wael Ghaith

This is great! thanks for sharing

Collapse
wldomiciano profile image
Wellington Domiciano

Nice! Thanks for sharing.

As a suggestion, you could replace @Components with @Directive+<ng-container> to avoid inserting unnecessary elements into the DOM, what do you think?

Collapse
ezzabuzaid profile image
ezzabuzaid Author • Edited

The initial implementation was using directives only, put simply, components are more clear, more friendly if you well.

I don't understand exactly how directives over ng-container will work, but using attribute directives with elements can work just the same.