2

I have an Angular 18 component that receives an observable, which I render on the page using an async pipe. I want to be able to modify the data within this component and then press a "Save" button.

Before The save button is pressed, how do I edit the values within this component? I am somewhat new to working with async pipes and normally I would have just subscribed within the component, but I am trying to stick to best practices and avoid subscribing where I can, instead have the async pipe take care of this.

Here's what I am doing, and

export class TimesheetSelectedDayComponent {
  private readonly activatedRoute = inject(ActivatedRoute);
  private readonly timesheetService = inject(TimesheetService);

  //whenever the route params OR the selected timesheet changes...
  selectedDay$ = combineLatest({
    params$: this.activatedRoute.paramMap,
    timesheet$: this.timesheetService.selectedTimesheet$,
  }).pipe(
    switchMap((results) =>
      //now get the matching day from the timesheet
      of(results.timesheet$?.days.find((d) => d.date.dayOfWeek === results.params$.get('dayOfWeek'))),
    ),
  );

  addAbsence(absence: ITimesheetAbsence): void {
    //How do I add this to the selectedDay.absences array?
  }

  removeAbsence(absenceTimeEntity: ITimesheetAbsence): void {
    //How do I remove this from the selectedDay.absences array?
  }
}
@if (selectedDay$ | async; as selectedDay) {
  <h5 class="card-header">{{ selectedDay.date }}</h5>
  <div class="card-body">
    <ul class="list-group">
        <li class="list-group-item">
          <app-time-entity-editor name="Work Day" [timeEntityObject]="selectedDay.workDay" />
        </li>
        <li class="list-group-item">
          <app-time-entity-editor name="Lunch" [timeEntityObject]="selectedDay.lunch" />
        </li>
        @for (absence of selectedDay.absences; track $index) {
          <li class="list-group-item">
            <app-time-entity-editor
              isRemovable="true"
              [name]="absence.name"
              [timeEntityObject]="absence"
              (removeClick)="removeAbsence($event)"
            />
          </li>
        }
      </ul>

      <app-absence-selector class="d-block mt-2" (selectAbsence)="addAbsence($event)" />
    </div>

    <button type="button" class="btn btn-outline-success btn-lg">Save</button>
  </div>
} @else {
  <h5 class="card-header">&nbsp;</h5>
  <div class="card-body">
    <app-spinner />
  </div>
}

Basically I want to have the ability to make changes to the data in my component before I save the changes and make the observable react in other areas where it is subscribed to

1
  • If you want to work with variables in .ts, you can not use pipe async. It's perfectly correct use subscribe and unsubscribe in this case.
    – Eliseo
    Commented Oct 10, 2024 at 15:11

3 Answers 3

1

Just pass selectedDay to methods. For removeAbsence it is easier to pass index instead of item itself.

addAbsence(day: DayOfWeek, absence: ITimesheetAbsence): void {
  day.absences.push(absence);
}

removeAbsence(day: DayOfWeek, absenceIndex: number): void {
  day.absences.splice(absenceIndex, 1);
}
1
  • Works perfectly, and by far the simplest solution here. Thanks, seems so obvious in retrospect now.
    – Chris Barr
    Commented Oct 10, 2024 at 16:09
0

I would try something like this:

const THROTTLE_DELAY_MILLISECONDS = 200; // protects users from accidentally clicking twice;

class TimesheetSelectedDayComponent {
  private readonly activatedRoute = inject(ActivatedRoute);
  private readonly timesheetService = inject(TimesheetService);
  private readonly addAbsenceEvent$ : Subject<ITimesheetAbsence> = new Subject();
  private readonly removeAbsenceEvent$ : Subject<ITimesheetAbsence> = new Subject();

  readonly selectedDay$ : Observable<SelectedDay> = combineLatest({
    params$: this.activatedRoute.paramMap,
    timesheet$: this.timesheetService.selectedTimesheet$,
  }).pipe(
    switchMap((results) =>
      //now get the matching day from the timesheet
      of(results.timesheet$?.days.find((d) => d.date.dayOfWeek === results.params$.get('dayOfWeek'))),
    )
  );

  readonly absences$: Observable<ITimesheetAbsence[]> = this.selectedDay$.pipe(
    map(sd => sd.absences),
    switchMap(a => merge(
      of(a),
      // append new absence to what we last emitted
      this.addAbsenceEvent$.pipe(
        throttleTime(THROTTLE_DELAY_MILLISECONDS),
        withLatestFrom(this.absences$),
        map(([newAbsence, absences]) => [...absences, newAbsence])
      ),
      // remove absence from what we last emitted
      this.removeAbsenceEvent$.pipe(
        throttleTime(THROTTLE_DELAY_MILLISECONDS),
        withLatestFrom(this.absences$),
        map(([absenseToRemove, absences]) => {
          const index = absences.indexOf(absenseToRemove);
          return (index === -1) ? absences : absences.splice(index, 1);
        }))
      )
    ),
    shareReplay(1)
  );

  addAbsence(absence: ITimesheetAbsence): void {
    this.addAbsenceEvent$.next(absence);
  }

  removeAbsence(absence: ITimesheetAbsence): void {
    this.removeAbsenceEvent$.next(absence);
  }
}

and then change

@for (absence of selectedDay.absences; track $index)

to

@for (absence of absences$ | async; track absence.name)
2
  • I can see how and why this would work, but this just seems so overly complex for just adding and removing items to a local array
    – Chris Barr
    Commented Oct 10, 2024 at 16:02
  • 1
    After reading the selected answer, I completely agree! 😁
    – JSmart523
    Commented Oct 11, 2024 at 2:09
0

I appreciate it when developers strive to follow best practices. Kudos for that!

You're right; it's a common practice, or some might say a best practice, to avoid handling the subscription manually and instead use the async pipe. The async pipe essentially subscribes to the observable when it's available and unsubscribes when the component is destroyed. This is why it's gained popularity – it reduces the likelihood of a developer forgetting to unsubscribe from a specific value.

I used to follow this approach, but with the introduction of new features, I now prefer one of the following options:


1. Using Angular Signals

You can leverage the rxjs/interop toSignal() function to convert your observable to a signal and then use the signal for all your tasks. Here's an example:

export class TimesheetSelectedDayComponent {
  private readonly activatedRoute = inject(ActivatedRoute);
  private readonly timesheetService = inject(TimesheetService);

  selectedDay = toSignal(combineLatest({
    params$: this.activatedRoute.paramMap,
    timesheet$: this.timesheetService.selectedTimesheet$,
  }).pipe(
    switchMap((results) =>
      of(results.timesheet$?.days.find((d) => d.date.dayOfWeek === results.params$.get('dayOfWeek'))),
    )
  ), { requireSync: true });

  addAbsence(absence: ITimesheetAbsence): void {
    this.selectedDay().absences.push(absence);
  }

  removeAbsence(absenceTimeEntity: ITimesheetAbsence): void {
    this.selectedDay().absences.splice(absenceIndex, 1);
  }
}

Note that using toSignal() will subscribe immediately and not wait for any conditions. However, based on your code, this shouldn't be an issue. Additionally, toSignal() is only allowed in an injection context.


2. Using rxjs: takeUntilDestroyed()

By adding this operator to your pipe, you can ensure that it unsubscribes when the component is destroyed. This way, you can subscribe to your observable without worrying about unsubscribing.

export class TimesheetSelectedDayComponent {
  private readonly activatedRoute = inject(ActivatedRoute);
  private readonly timesheetService = inject(TimesheetService);

  #selectedDay$ = combineLatest({
    params$: this.activatedRoute.paramMap,
    timesheet$: this.timesheetService.selectedTimesheet$,
  }).pipe(
    switchMap((results) =>
      of(results.timesheet$?.days.find((d) => d.date.dayOfWeek === results.params$.get('dayOfWeek'))),
    ),
    takeUntilDestroyed()
  );

  selectedDay: any;

  constructor() {
    this.#selectedDay$.subscribe(
      (res) => {
        this.selectedDay = res;
      }
    );
  }

  addAbsence(absence: ITimesheetAbsence): void {
    this.selectedDay.absences.push(absence);
  }

  removeAbsence(absenceTimeEntity: ITimesheetAbsence): void {
    this.selectedDay.absences.splice(absenceIndex, 1);
  }
}

Note that this also requires an injection context.


3. Subscribing and Unsubscribing manually

If none of the above solutions work, I would subscribe to the observable and unsubscribe in ngOnDestroy(). However, in this case, I would recommend following @JSmart523's solution.

In conclusion, I highly recommend using the approach with Signals, as Angular is moving in this direction, and it ensures best practices while keeping your project up-to-date. Angular Signals also offer other benefits including performance benefits.


See also:

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.