Naren Murali
Naren Murali

Reputation: 57986

Angular signals react to local state changes, but not to changes from input signal

I have this scenario, where the parent component will fetch user details and show then on the UI, code below:

@Component({
  selector: 'app-root',
  imports: [Child, FormsModule],
  template: `
    <div> User Details</div>
    @if(resource.value(); as user) {
      <div> Title: {{user.title}}</div>
      <app-like-button [likes]="user.id"/>
    }
    <button (click)="refresh()">Reload User Data</button>
  `,
})
export class App {
  http = inject(HttpClient);
  id = signal(1);
  resource: ResourceRef<UserDetails> = rxResource({
    request: () => this.id(),
    loader: ({ request: id }: any) =>
      this.http.get<UserDetails>(
        `https://jsonplaceholder.typicode.com/todos/${id}`
      ),
  });

  refresh() {
    this.resource.reload();
  }
}

Similarly I have a likes component which is responsible for showing the count and the like/dislike button.

@Component({
  selector: 'app-like-button',
  imports: [FormsModule],
  template: `
  <div> Child </div>
    <div>likes: {{likes()}}</div>
    <button (click)="like()">Like</button>
    <button (click)="unLike()">Dislike</button>
  `,
})
export class Child {

  // local state for this component
  likes: ModelSignal<number> = model.required<number>();

  constructor() {
    effect(() => {
      const likesCount = this.likes();
      // trigger an API call to save the like button press:
      console.log(likesCount);
    });
  }

  like() {
    this.likes.update((likesPrev: number) => likesPrev + 1);
  }

  unLike() {
    this.likes.update((likesPrev: number) => likesPrev - 1);
  }
}

In the below code, I have an effect which reacts to changes of the likes signal, by react I mean the changes are saved to the database using a put API call.

The problem is that, I am using an effect which reacts to likes signal changes. So during initialization the PUT API call is called, which is not necessary.

I would like the API to react to only local state updates of likes signal.

Stackblitz Demo

Expected result, is that API call ( represented by console.log) should not fire on initial load.

Upvotes: 1

Views: 85

Answers (2)

Daniel Gimenez
Daniel Gimenez

Reputation: 20599

How about taking this into a different direction. Make the Child component a dumb, presentational component. In the parent component do whatever put you need to by registering a callback to the likesChange event on the Child.

Child

@Component({
  selector: 'app-like-button',
  imports: [FormsModule],
  template: `
  <div> Child </div>
    <div>likes: {{likes()}}</div>
    <button (click)="updateLikes(1)">Like</button>
    <button (click)="updateLikes(-1)">Dislike</button>
  `,
})
export class Child {
  readonly likes: ModelSignal<number> = model.required<number>();

  protected updateLikes(relativeChange: number): void {
    this.likes.update(x => x + relativeChange);
  }
}

Parent (template fragment)

<app-like-button [likes]="user.likes" (likesChange)="updateLikes($event)" />

Parent (component class fragment)

protected updateLikes(likeCount: number): void {
  const user = untracked(this.resource.value);
  this.http.put(/* ... update likes ...*/);
}

By doing this, the Child component becomes much more flexible and reusable, and you've gotten around any hackiness from using an effect to handle the update. It also makes a certain amount of design sense that the class that loads the data would be the same to update the data.

Upvotes: 3

Michael Small
Michael Small

Reputation: 31

If you convert to use a linkedSignal rather than a model, then you can access the previous state of the signal and allow having undefined as the initial state. And subsequently, no side effects like the PUT inside of the signal effect until there is a value.

Additionally, I think that a linkedSignal is arguably a better signal primitive than a model signal in this scenario for local state. The likes was only a model for the sake of local state, but otherwise as far as the sample code goes it is not being used to sync state in the parent or emit outputs to the parent. Therefore, a one way & immutable input with an associated likesLocal is a better fit.

export class Child {
    // no longer local
    likes: InputSignal<number> = input.required<number>();

    // local state for this component
    likesLocal = linkedSignal({
        source: this.likes,
        computation: (res, prev) => {
            return prev ? res : undefined;
        },
    });

    constructor() {
        effect(() => {
            const likesCount = this.likesLocal();
            if (this.likesLocal() !== undefined) {
                console.log(likesCount);
            }
        });
    }
}

Upvotes: 2

Related Questions