Search code examples
angularhttpsignals

Angular Service - HTTP request updating a signal


I have a service like this:

const initialState: IUserState | null = null

@Injectable({
    providedIn: 'root'
})
export class AuthService {

private user = signal(initialState)

constructor (private http: HttpClient) {
  // setTimeout(() => this.user.set({ ...someFakeData }), 5000)
}

isAuthenticated = () => computed(() => !!this.user())

logIn = ({ email, password }: ILoginPayload) =>
  this.http.post<LoginResponse>(`some/url`, { email, password })
    .subscribe((data) => {
      this.user.set(data)
    })

updateSignal = () => this.user.set({ ...someFakeData })

}

export interface IUserState {
  platform_id: string
  name: string
  email: string
  picture_url: string
}

I also have a Login Component that calls logIn method like so:

  onSubmit = () =>
    this.authService.logIn({
      email: this.loginForm.value.email!,
      password: this.loginForm.value.password!
    })

And a third component that listens to user signal:

export class AppComponent {

  private authService = inject(AuthService)


  isAuthenticated = this.authService.isAuthenticated()
}

When I try to update signal from .subscribe method the app component will just not pick up the change. But when I uncomment timeout in the constructor and update the signal from there, the component is picking up the change.

Also I added a method that will just update the signal, and when this method is called, signal gets updated and components pick it up without a problem.

So for some reason I am unable to update signal from my http Observable so that the change is being picked through the app. Signal gets updated but no component knows about it...

What am I doing wrong? Or is there any better pattern to handle http requests with signals. I tried to find some solutions online, but found nothing... I am using Angular version 17.0.8


Solution

  • I hope my answer will help a bit :)

    I am not sure what kind of change detection strategy you have setup in the component. I assume OnPush. I cannot see anything about zone/zoneless mentioned - I assume zone as it is by default.

    1. this isAuthenticated = () => computed(() => !!this.user()) should be isAuthenticated = computed(() => !!this.user())
    2. signals will automatically trigger change detection when they are utilized in a template - you just need to attach one of the signals to the template
    3. try to avoid subscribing observables outside of components - it is a kind of anti-pattern and may lead to pitfalls

    See this exapmle code (stackblitz):

    import { JsonPipe } from '@angular/common';
    import { HttpClient, provideHttpClient } from '@angular/common/http';
    import {
      ChangeDetectionStrategy,
      Component,
      computed,
      DestroyRef,
      inject,
      Injectable,
      OnInit,
      signal,
    } from '@angular/core';
    import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
    import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
    import { bootstrapApplication } from '@angular/platform-browser';
    import { finalize, tap } from 'rxjs';
    import 'zone.js';
    
    export interface IUserState {
      platform_id: string;
      name: string;
      email: string;
      picture_url: string;
    }
    const initialState: IUserState | null = null;
    
    @Injectable({
      providedIn: 'root',
    })
    export class AuthService {
      #user = signal(initialState);
      #login = signal(false);
    
      user = this.#user.asReadonly();
      login = this.#login.asReadonly();
      isAuthenticated = computed(() => (!!this.#user() ? 'YES' : 'NO'));
    
      http = inject(HttpClient);
    
      logIn = ({ email, password }: any) => {
        this.#login.set(true);
        return this.http
          .post<any>(`https://jsonplaceholder.typicode.com/posts`, {
            email,
            password,
          })
          .pipe(
            tap((response) => this.setUser(response)),
            finalize(() => this.#login.set(false))
          );
      };
    
      setUser = (value: IUserState | null) => this.#user.set(value);
    }
    
    @Component({
      selector: 'app-root',
      standalone: true,
      template: `
        <h1>Hello from {{ name }}!</h1>
        <form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
          <p><input type="text" formControlName="email" placeholder="Email" [disabled]="authService.login()" /></p>
          <p><input type="password" formControlName="password" placeholder="Password" [disabled]="authService.login()" /></p>
          <p><button type="submit" [disabled]="authService.login()">Log in</button></p>
        </form>
        <pre>{{ authService.isAuthenticated() | json }}</pre>
        <pre>{{ testValue | json }}</pre>
      `,
      changeDetection: ChangeDetectionStrategy.OnPush,
      imports: [JsonPipe, ReactiveFormsModule],
    })
    export class App implements OnInit {
      authService = inject(AuthService);
      destroyRef = inject(DestroyRef);
    
      testValue: IUserState | null = null;
    
      loginForm = new FormGroup({
        email: new FormControl(null),
        password: new FormControl(null),
      });
    
      name = 'Angular';
    
      ngOnInit() {}
    
      onSubmit() {
        this.authService
          .logIn(this.loginForm.value)
          .pipe(
            tap((response) => (this.testValue = response)),
            takeUntilDestroyed(this.destroyRef)
          )
          .subscribe();
      }
    }
    
    bootstrapApplication(App, { providers: [provideHttpClient()] });