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);
}