End-to-End Workflow

Service, resource factory, standalone components, and app config — a full contacts feature in Angular.

#Building a contacts feature from scratch (Angular)

The same feature as the React guide, built with Angular 19+ — AngularResourceService, createResourceInject, standalone components, and Signals throughout.

#Step 1 — Types

Identical to the React example — shared types work across every package.

typescript
// contacts/contacts.types.ts
import type { VSId } from '@void-snippets/core';

export namespace Contact {
  export type Id = VSId<string, 'Contact'>;

  export interface Base {
    _id:   Id;
    name:  string;
    email: string;
    phone: string;
  }

  export interface Detail extends Base {
    createdBy: { name: string };
    notes:     string;
    createdAt: string;
  }

  export namespace Apis {
    export interface Create { name: string; email: string; phone: string; }
    export interface Update { name?: string; email?: string; phone?: string; notes?: string; }
  }
}

#Step 2 — HTTP service

typescript
// contacts/contacts.service.ts
import { Injectable } from '@angular/core';
import { AngularResourceService } from '@void-snippets/angular';
import type { Contact } from './contacts.types';

@Injectable({ providedIn: 'root' })
export class ContactsService extends AngularResourceService<
  Contact.Id,
  Contact.Base,
  Contact.Detail,
  Contact.Apis.Create,
  Contact.Apis.Update
> {
  constructor() {
    super('/api/v1/contacts');
  }
}

#Step 3 — Resource factory

typescript
// contacts/contacts.resource.ts
import { inject } from '@angular/core';
import { createResourceInject } from '@void-snippets/angular';
import { ContactsService } from './contacts.service';
import type { Contact } from './contacts.types';

export function useContactResource() {
  return createResourceInject('contacts', inject(ContactsService), {
    defaultParams: { page: 1, limit: 20 },
    optimistic: {
      update: (cache, { _id, payload }) =>
        cache.map((c) => (c._id === _id ? { ...c, ...payload } : c)),
      remove: (cache, id) => cache.filter((c) => c._id !== id),
      create: (cache, { payload, tempId }) => [
        { ...payload, _id: tempId as Contact.Id },
        ...cache,
      ],
    },
  });
}

#Step 4 — App configuration

typescript
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { provideVoidAngular } from '@void-snippets/angular';
import { authInterceptor } from './core/auth.interceptor';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
    provideVoidAngular({
      queryClientConfig: {
        defaultOptions: {
          queries:   { staleTime: 30_000, retry: 1 },
          mutations: { retry: 0 },
        },
      },
    }),
  ],
};

#Step 5 — List page component

typescript
// contacts-list/contacts-list.component.ts
import { Component, signal, computed } from '@angular/core';
import { useContactResource } from '../contacts.resource';
import type { Contact } from '../contacts.types';

@Component({
  selector: 'app-contacts-list',
  template: `
    @if (contacts.isLoading()) {
      <app-table-skeleton />
    } @else if (contacts.isError()) {
      <app-error-state
        [message]="contacts.error()?.message"
        (retry)="contacts.refetch()"
      />
    } @else {
      @if (contacts.isRefetching()) { <mat-progress-bar mode="indeterminate" /> }

      <div class="toolbar">
        <mat-form-field>
          <input matInput placeholder="Search…" [value]="search()"
                 (input)="onSearch($event)" />
        </mat-form-field>
      </div>

      <table mat-table [dataSource]="contacts.list()">
        <ng-container matColumnDef="name">
          <th mat-header-cell *matHeaderCellDef>Name</th>
          <td mat-cell *matCellDef="let c">{{ c.name }}</td>
        </ng-container>
        <ng-container matColumnDef="email">
          <th mat-header-cell *matHeaderCellDef>Email</th>
          <td mat-cell *matCellDef="let c">{{ c.email }}</td>
        </ng-container>
        <ng-container matColumnDef="actions">
          <th mat-header-cell *matHeaderCellDef></th>
          <td mat-cell *matCellDef="let c">
            <button mat-icon-button (click)="openEdit(c)">
              <mat-icon>edit</mat-icon>
            </button>
            <button mat-icon-button color="warn"
                    [disabled]="mutations.remove.isPending()"
                    (click)="mutations.remove.mutate(c._id)">
              <mat-icon>delete</mat-icon>
            </button>
          </td>
        </ng-container>
        <tr mat-header-row *matHeaderRowDef="columns"></tr>
        <tr mat-row *matRowDef="let row; columns: columns"></tr>
      </table>

      <mat-paginator
        [pageIndex]="contacts.pagination().page - 1"
        [pageSize]="contacts.pagination().limit"
        [length]="contacts.pagination().totalDocuments"
        (page)="onPage($event)"
      />
    }

    <app-contact-dialog
      [open]="dialogOpen()"
      [contact]="dialogContact()"
      [saving]="mutations.create.isPending() || mutations.update.isPending()"
      (save)="onSave($event)"
      (close)="dialogOpen.set(false)"
    />

    <button mat-fab color="primary" (click)="openCreate()">
      <mat-icon>add</mat-icon>
    </button>
  `,
})
export class ContactsListComponent {
  columns = ['name', 'email', 'actions'];

  private resource = useContactResource();
  mutations = this.resource.injectMutations();

  search        = signal('');
  page          = signal(1);
  limit         = signal(20);
  dialogOpen    = signal(false);
  dialogContact = signal<Contact.Base | null>(null);

  contacts = this.resource.injectList(
    computed(() => ({ page: this.page(), limit: this.limit(), search: this.search() })),
  );

  onSearch(e: Event) {
    this.search.set((e.target as HTMLInputElement).value);
    this.page.set(1);
  }

  onPage({ pageIndex, pageSize }: { pageIndex: number; pageSize: number }) {
    this.page.set(pageIndex + 1);
    this.limit.set(pageSize);
  }

  openCreate() { this.dialogContact.set(null); this.dialogOpen.set(true); }
  openEdit(c: Contact.Base) { this.dialogContact.set(c); this.dialogOpen.set(true); }

  async onSave(formData: Contact.Apis.Create | Contact.Apis.Update) {
    const existing = this.dialogContact();
    if (existing) {
      await this.mutations.update.mutateAsync({ _id: existing._id, payload: formData as Contact.Apis.Update });
    } else {
      await this.mutations.create.mutateAsync(formData as Contact.Apis.Create);
    }
    this.dialogOpen.set(false); // closes only on success — mutateAsync throws on error
  }
}

#Step 6 — Detail page component

typescript
// contact-detail/contact-detail.component.ts
import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs/operators';
import { useContactResource } from '../contacts.resource';

@Component({
  selector: 'app-contact-detail',
  template: `
    @if (result.isLoading()) {
      <app-detail-skeleton />
    } @else if (result.isError()) {
      <app-error-state [message]="result.error()?.message" />
    } @else if (result.item(); as contact) {
      <h1>{{ contact.name }}</h1>
      <dl>
        <dt>Email</dt><dd>{{ contact.email }}</dd>
        <dt>Phone</dt><dd>{{ contact.phone }}</dd>
        <dt>Notes</dt><dd>{{ contact.notes }}</dd>
        <dt>Created by</dt><dd>{{ contact.createdBy.name }}</dd>
      </dl>
    }
  `,
})
export class ContactDetailComponent {
  private route    = inject(ActivatedRoute);
  private resource = useContactResource();

  private contactId = toSignal(
    this.route.paramMap.pipe(map((p) => p.get('contactId') ?? undefined)),
  );

  result = this.resource.injectGet(this.contactId);
}