javascriptangulartypescriptsignalsangular-signals

How to I keep two signals in sync (Service and Component)


I have this component, which is accepting a parameter as input.required ( say ID ).

Above are the requirements of my scenario, below if my minimal reproducible code:

Service:

@Injectable({
  providedIn: 'root',
})
export class SomeService {
  http = inject(HttpClient);
  serviceIdSignal: WritableSignal<number> = signal(0); // <- I want to sync this at the component level.

  rxResource = rxResource({
    request: () => this.serviceIdSignal(),
    loader: ({ request: id }) => {
      return this.http.get(`https://jsonplaceholder.typicode.com/todos/${id}`);
    },
  });

  resource = resource({
    request: () => this.serviceIdSignal(),
    loader: ({ request: id, abortSignal }) => {
      return fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
        signal: abortSignal,
      }).then((r) => r.json());
    },
  });
}

Child Component:

@Component({
  selector: 'app-child',
  template: `
        <div></div>
      `,
})
export class Child {
  someService = inject(SomeService);
  componentIdSignal: InputSignal<number> = input.required({
    alias: 'id',
  });
}

Root Component:

@Component({
  selector: 'app-root',
  imports: [Child, JsonPipe],
  template: `
    <app-child [id]="id()"/>
    <hr/>
    {{id()}}
    <hr/>
    @if(someService.rxResource.status() === rs.Resolved) {
      {{someService.rxResource.value() | json}}
    } @else {
      Loading...
    }
    <hr/>
    @if(someService.rxResource.status() === rs.Resolved) {
      {{someService.resource.value() | json}}
    } @else {
      Loading...
    }
  `,
})
export class App {
  rs = ResourceStatus;
  someService = inject(SomeService);
  id = signal(1);
}

Stackblitz Demo


Solution

  • Syncing signals can we achieved using an effect, because it feels like updating another signal falls under the category of side effect and these should be done using an effect.

    So we initialize an effect, which will do the sync to the service, this is very similar to a linkedSignal behavior (but we do not have access to the component from the service, so we use this method).

    @Component({...})    
    export class Child {
      someService = inject(SomeService);
      componentIdSignal: InputSignal<number> = input.required({
        alias: 'id',
      });
    
      constructor() {
        effect(() => {
          this.someService.serviceIdSignal.set(this.componentIdSignal());
        });
      }
    }
    

    To demonstrate this sync, we can create an incrementing ID at the root component level, this will trigger the resource APIs every 2 seconds.

    @Component({...})
    export class App {
      rs = ResourceStatus;
      someService = inject(SomeService);
      id = signal(1);
    
      ngOnInit() {
        setInterval(() => {
          this.id.update((prev) => ++prev);
        }, 2000);
      }
    }
    

    Full Code:

    import {
      Component,
      signal,
      Injectable,
      WritableSignal,
      input,
      InputSignal,
      effect,
      inject,
      resource,
      ResourceStatus,
    } from '@angular/core';
    import { bootstrapApplication } from '@angular/platform-browser';
    import { rxResource } from '@angular/core/rxjs-interop';
    import { HttpClient, provideHttpClient } from '@angular/common/http';
    import { JsonPipe } from '@angular/common';
    
    @Injectable({
      providedIn: 'root',
    })
    export class SomeService {
      http = inject(HttpClient);
      serviceIdSignal: WritableSignal<number> = signal(0);
    
      rxResource = rxResource({
        request: () => this.serviceIdSignal(),
        loader: ({ request: id }) => {
          return this.http.get(`https://jsonplaceholder.typicode.com/todos/${id}`);
        },
      });
    
      resource = resource({
        request: () => this.serviceIdSignal(),
        loader: ({ request: id, abortSignal }) => {
          return fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
            signal: abortSignal,
          }).then((r) => r.json());
        },
      });
    }
    
    @Component({
      selector: 'app-child',
      template: `
        <div></div>
      `,
    })
    export class Child {
      someService = inject(SomeService);
      componentIdSignal: InputSignal<number> = input.required({
        alias: 'id',
      });
    
      constructor() {
        effect(() => {
          this.someService.serviceIdSignal.set(this.componentIdSignal());
        });
      }
    }
    
    @Component({
      selector: 'app-root',
      imports: [Child, JsonPipe],
      template: `
        <app-child [id]="id()"/>
        <hr/>
        {{id()}}
        <hr/>
        @if(someService.rxResource.status() === rs.Resolved) {
          {{someService.rxResource.value() | json}}
        } @else {
          Loading...
        }
        <hr/>
        @if(someService.rxResource.status() === rs.Resolved) {
          {{someService.resource.value() | json}}
        } @else {
          Loading...
        }
      `,
    })
    export class App {
      rs = ResourceStatus;
      someService = inject(SomeService);
      id = signal(1);
    
      ngOnInit() {
        setInterval(() => {
          this.id.update((prev) => ++prev);
        }, 2000);
      }
    }
    
    bootstrapApplication(App, {
      providers: [provideHttpClient()],
    });
    

    Stackblitz Demo