angulartypescriptngrxngrx-signal-store

Angular signal store won't update the state when promise completes


I've designed a store as shown below. It seems to connect the dots as described in this blog. All the console logs confirm that I'm passing the relevant lines. The issue is that the endpoint gets invoked but when I release the breakpoint in the controller, no update is available in the store. Somehow, the state is loading at the time of started execution and nothing happens when the execution is completed (i.e. when the promise is resolved). I've been looking and comparing for a few hours and I simply fail to see where I'm dropping the ball. I suspect it's the promise and async but I can't find how to "push it over the cliff" to the data available and notify the component that new info has arrived.

export interface Thing {
    id: string;
    name: string;
    code: string;
}

export const ThingStore = signalStore(
    withState<Thing>({ id: ZERO_GUID, name: "", code: "" } as Thing),
    withProps(() => ({
        _thingService: inject(ThingService),
    })),
    withProps((state) => ({
        _thingResource: resource({
            request: state.id,
            //loader: async () => {
            loader: () => {
                console.log("loader for resource");
                //return await state._thingService.load(state.id());
                return state._thingService.load(state.id());
            },
        }),
    })),
    withComputed((state) => ({
        thingComputed: computed(() => state._thingResource.value()),
        loading: computed(() => state._thingResource.isLoading()),
    })),
    withMethods((state) => ({
        init: (id: string) => {
            console.log("init and patch state", id);
            patchState(state, { id });
        }
    }))
);

The service executes the method (I can see the text in the console). However, like I mentioned, the call actually doesn't notify the component that it's done loading.

public async load(id: string): Promise<Thing> {
    const url = `${this.baseUrl}/things`;
    console.log("loading", id, url);
    const request = this.httpClient.get<Thing>(id).pipe(take(1));
    return await lastValueFrom(request);
}

I need help pin-pointing a relevant spot for further debug and preferably a suggestion on what I can add/remove to make it fetch the data. It's like the promise doesn't trigger change of state in the store. I'm confused. In the network tab, the call is shown and contains correct payload in the response. The effect fires but before I see the print from the service. I was expecting that when the promise is realized, then the loading flag is false, which (since it's a signal) will trigger the effect and re-update the data showing the fetched thing.

constructor() {
    effect(() => {
        this.store.init(this.id());

        untracked(() => {
            if (!this.store.loading()) 
                console.log("you got some", this.store.thingComputed());
            else console.log("No thing yet");
            console.log("name by retry", this.store.nameComputed());
        });
    });
}

Solution

  • If you want to update the individual signals inside the state, you can use withMethods and define an effect which with patchState the values from the resource to the signal store state.

    ...
    withHooks({
      onInit(store) {
        effect(() => {
          const thingData = store._thingResource.value();
          patchState(store, {
            title: thingData.title,
            completed: thingData.completed,
          });
        });
      },
    })
    ...
    

    Because you are using untracked inside the effect. The signal updates (The changes coming from this.store.loading(), this.store.thingComputed() and this.store.nameComputed()) are not detected by the effect (The signals inside the untracked are not tracked).

    Untracked - Angular.dev

    Execute an arbitrary function in a non-reactive (non-tracking) context. The executed function can, optionally, return a value.

    If you comment out the untracked you code will work fine.

    constructor() {
      effect(() => {
        this.store.init(this.id());
    
        // untracked(() => {
        if (!this.store.loading())
          console.log('you got some', this.store.thingComputed());
        else console.log('No thing yet');
        // console.log("name by retry", this.store.nameComputed());
        // });
      });
    }
    

    A use case for untracked, is when you are updating a signal inside an effect but you do not want the effect to fire again (infinite loop sometimes caused).

    constructor() {
      effect(() => {
        const data = this.name();  
        // infinite loop due to below signal update
        // this.name.set('infinite loop');
    
        // use untracked to break infinite loop, by not tracking this update
        untracked(() => {
          this.name.set('no infinite loop');
        });
      });
    }
    

    Full Code:

    import {
      Component,
      computed,
      effect,
      inject,
      Injectable,
      resource,
      signal,
      untracked,
    } from '@angular/core';
    import { DecimalPipe, JsonPipe } from '@angular/common';
    import { bootstrapApplication } from '@angular/platform-browser';
    import 'zone.js';
    import { provideHttpClient, HttpClient } from '@angular/common/http';
    import {
      signalStore,
      withState,
      withProps,
      withComputed,
      withMethods,
      patchState,
      withHooks,
    } from '@ngrx/signals';
    
    export interface Thing {
      id: string;
      userId: string;
      name: string;
      title: string;
      code: string;
      completed: boolean;
    }
    
    @Injectable({ providedIn: 'root' })
    export class ThingService {
      http = inject(HttpClient);
      load(id: any) {
        return fetch(`https://jsonplaceholder.typicode.com/todos/${id}`).then(
          (res) => res.json()
        );
      }
    }
    
    export const ThingStore = signalStore(
      { providedIn: 'root' },
      withState<Thing>({ id: 'ZERO_GUID', title: '', code: '' } as Thing),
      withProps(() => ({
        _thingService: inject(ThingService),
      })),
      withProps((state) => ({
        _thingResource: resource({
          request: state.id,
          loader: () => {
            console.log('loader for resource');
            return state._thingService.load(state.id());
          },
        }),
      })),
      withComputed((state) => ({
        thingComputed: computed(() => state._thingResource.value()),
        loading: computed(() => state._thingResource.isLoading()),
        nameComputed: computed(() => state._thingResource.value()?.title),
      })),
      withMethods((state) => ({
        init: (id: string) => {
          console.log('init and patch state', id);
          patchState(state, { id });
        },
      })),
      withHooks({
        onInit(store) {
          effect(() => {
            const thingData = store._thingResource.value();
            patchState(store, {
              title: thingData.title,
              completed: thingData.completed,
            });
          });
        },
      })
    );
    
    @Component({
      selector: 'app-root',
      standalone: true,
      imports: [JsonPipe],
      template: `
      <div>{{this.store.thingComputed() | json}}</div>
      <div>{{this.store.title() | json}}</div>
      <div>{{this.store.id() | json}}</div>
      `,
    })
    export class App {
      store = inject(ThingStore);
      id = signal('1');
      constructor() {
        effect(() => {
          this.store.init(this.id());
    
          // untracked(() => {
          if (!this.store.loading())
            console.log('you got some', this.store.thingComputed());
          else console.log('No thing yet');
          console.log('name by retry', this.store.nameComputed());
          console.log('title:', this.store.title());
          console.log('id:', this.store.id());
          // });
        });
      }
    }
    
    bootstrapApplication(App, { providers: [provideHttpClient()] });
    

    Stackblitz Demo