Tim Hardy
Tim Hardy

Reputation: 1737

Ngrx SignalStore - How to handle async "saving" or "deleting" properties on a per-entity basis

I love the maintainability given by the abstractions provided by a good store pattern, but I also feel the loss of knowing exactly when certain asynchronous functions are completed. Also, with the older, full Ngrx Redux-compatible store, I could publish success and failed actions that contained information about exactly which things just completed or failed, etc. I'm struggling to handle many features with the new SignalStore that were effortless before.

Using plain signals, I can know exactly when a particular operation has completed and have my UX respond accordingly. In this particular scenario, I have a list of items on the screen, each with its own "delete" button, and I want to both disable and change the text of a clicked button to "deleting...". I need to keep track of exactly which item is being deleted, and when the delete is complete. I can get this to work via the following in my component...

productUx = new Map<IProduct, { deleting: boolean }>();

onDelete(product: IProduct) {
  this.productUx.set(product, { deleting: true });
  this.productService.delete(product.id)
    .pipe(
      takeUntil(this.ngUnsubscribe),
      finalize(() => {
        this.productUx.set(product, { deleting: false });
      })
    )
    .subscribe();
}

I have a productUx Map object that basically just extends an IProduct, allowing me to add Ux specific properties to any entity. I also have an async-button directive that just needs a boolean variable to trigger the change in button state. This works because I know when async operations complete. Moving to an Ngrx SignalStore, I need to handle all of these async flags inside the store proper. I was able to get it to work with the following in my store...

export interface IProductState {
  deleting: Map<EntityId, boolean>;
}

const _delete = async (id: EntityId) => {
  store.deleting().set(id, true);
  await entityService.deleteAsPromise(id);
  patchState(store, removeEntity(id)); // I'm using withEntities<IProduct>()
  store.deleting().set(id, false);
}

...then in my component html...

<button (click)="onDelete(product)" app-async-button [asyncInProgress]="productStore.deleting().get(product.id)">Delete</button>

This appears to be working, but it feels "off" to me. I need to know when something both begins and ends on a per-entity basis. Is there a better or more elegant way to handle what are basically on/off flags on a per-entity basis inside an Ngrx SignalStore?

Update

Are there no better solutions to wanting to track meta-data on each item in an array/collection? The issue with the above is that a change to any item signals a change to all.

Upvotes: 1

Views: 1713

Answers (2)

Serhii Zhydetskyi
Serhii Zhydetskyi

Reputation: 174

First of all, you have to use NgRx SignalStore withEntites like explained in this article on Medium

Read carefully the paragraph called BooksStore implementation. Pay attention how isLoading property is implemented for SignalStore withEntities.

When you have done it like in the article follow next steps: (the example is based on a list of books so I will use name book across the explanation)

  1. You have to create a property processingBook on the books overview component controller, where have to save book instance which is passed to removeBook(book: Ibook) function
    processingBook: IBook | null = null;

    bookStore = inject(BooksStore);
    
    ...
    removeBook(book: IBook) {
        this.processingBook = book;
        this.bookStore.remove(book);
    }
  1. Create a function called isBookingProcessing(): Signal<boolean> which returns computed signal which based on this.bookStore.isLoading() signal and this.processingBook property. You will use the function on the view. It's the key function which manages every book loading state.
    isBookProcessing(book: IBook): Signal<boolean> {
        return computed(() => {
            return !!(book.id === this.processingBook?.id && this.bookStore.isLoading());
        })
    }
  1. Create en effect in the component constructor which resets processingBook property
constructor() {
        effect(() => {
            if (!this.bookStore.isLoading()) {
                this.processingBook = null;
                console.log(this.processingBook)
            }
        });
    }

You are almost done! Now you just need to use it on the view.

In the end your code should looks like:

@Component({
    selector: 'app-root',
    standalone: true,
    imports: [
        JsonPipe,
        MatList,
        MatDivider,
        MatListItem,
        MatProgressSpinner,
        ReactiveFormsModule
    ],
    providers: [BooksStore],
    template: `
        @if (bookStore.isLoading() && !processingBook) {
            <div class="backdrop">
                <mat-spinner class="spinner"></mat-spinner>
            </div>
        }
        <mat-list>
            <mat-divider></mat-divider>

            @for (book of bookStore.entities(); track book.id) {
                <div class="item-holder">
                    @if (isBookProcessing(book)()) {
                        <div class="backdrop">
                            <mat-spinner class="spinner"></mat-spinner>
                        </div>
                    }
                    <mat-list-item><span class="text">{{ book.name }} with id:{{ book.id }}</span>
                        <button class="btn-remove" (click)="removeBook(book)">remove</button>
                    </mat-list-item>
                    <mat-divider></mat-divider>
                </div>
            }
        </mat-list>
        <button (click)="addBook()">addBook</button>
    `,
    styleUrl: './app/app.component.css'
})

export class AppComponent {

    bookStore = inject(BooksStore);

    processingBook: IBook | null = null;

    constructor() {
        effect(() => {
            if (!this.bookStore.isLoading()) {
                this.processingBook = null;
                console.log(this.processingBook)
            }
        });
    }

    isBookProcessing(book: IBook): Signal<boolean> {
        return computed(() => {
            return !!(book.id === this.processingBook?.id && this.bookStore.isLoading());
        })
    }

    addBook() {
        this.bookStore.add({
            id: RandomHelper.getRandomInt(10000, 99999),
            name: RandomHelper.getRandomString(5),
            pageCount: 0
        })
    }

    removeBook(book: IBook) {
        this.processingBook = book;
        this.bookStore.remove(book);
    }
}

See working example on stackblitz.com

Github repository

If you cant open stackblitz example by click on, open githab and slick stackblitz link there.

And small gif how does it looks like :)

enter image description here

Upvotes: 0

timdeschryver
timdeschryver

Reputation: 15505

Signal store is extensible, this means you can write custom (generic) features and plug them into a signal store. A "request" state is a good case for this.

As an example see the example:

Upvotes: 0

Related Questions