End-to-End Workflow
Routes, hooks, optimistic updates, and a full list page example.
#Building a contacts feature from scratch
This is the complete workflow — types, HTTP service, React hooks, routes, and the page component — all wired together.
#Step 1 — Types
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.api.ts
import { ResourceService } from '@void-snippets/client';
import type { Contact } from './contacts.types';
class ContactsApiService extends ResourceService<
Contact.Id, Contact.Base, Contact.Detail, Contact.Apis.Create, Contact.Apis.Update
> { constructor() { super('/contacts'); } }
export const ContactsApis = new ContactsApiService();#Step 3 — React hooks
typescript
// contacts/contacts.hooks.ts
import { createResourceHooks } from '@void-snippets/react';
import { ContactsApis } from './contacts.api';
import type { Contact } from './contacts.types';
export const contactHooks = createResourceHooks('contacts', ContactsApis, {
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,
],
onError: (err, op) =>
toast.error(`Failed to ${op.kind}: ${err.message}`),
},
});#Step 4 — Routes
typescript
// routes.ts
import { createRouteContract, defineRoute } from '@void-snippets/react';
export const AppRoutes = createRouteContract({
contacts: {
list: defineRoute('/contacts', {
breadcrumb: 'Contacts',
title: 'Contact Management',
}).search<{ page: number; sort?: 'asc' | 'desc'; q?: string }>(),
detail: defineRoute('/contacts/:contactId', {
breadcrumb: 'Contact Detail',
}),
},
});#Step 5 — The page component
tsx
// ContactsPage.tsx
import { contactHooks } from './contacts.hooks';
import { AppRoutes } from '@/routes';
export function ContactsPage() {
const navigate = useNavigate();
const modal = useModal<Contact.Base>();
const { alert, showAlert, hideAlert } = useAlertMessage(4000);
const { queryParams, onPaginationChange, resetPagination } = usePagination(1, 20);
const { search, setSearch, clearSearch } = useTypedSearchParams(AppRoutes.contacts.list);
const { list, pagination, isLoading, isRefetching, isError, error, refetch } =
contactHooks.useList({
...queryParams,
sort: search.sort,
q: search.q,
});
const { create, update, remove } = contactHooks.useMutations();
const handleSave = async (formData: Contact.Apis.Create | Contact.Apis.Update) => {
try {
if (modal.data) {
await update.mutateAsync({ _id: modal.data._id, payload: formData as Contact.Apis.Update });
showAlert('Contact updated!', 'success');
} else {
await create.mutateAsync(formData as Contact.Apis.Create);
showAlert('Contact created!', 'success');
}
modal.closeModal();
} catch (err) {
showAlert(err instanceof Error ? err.message : 'Something went wrong', 'error');
}
};
if (isLoading) return <TableSkeleton />;
if (isError) return <ErrorState message={error?.message} onRetry={refetch} />;
return (
<>
{alert.isVisible && (
<Alert severity={alert.type} onClose={hideAlert}>{alert.message}</Alert>
)}
<PageHeader title="Contacts">
<Button onClick={modal.openCreateModal}>+ New Contact</Button>
</PageHeader>
<Toolbar>
<SearchInput
value={search.q ?? ''}
onChange={q => { setSearch({ q: q || undefined, page: 1 }); resetPagination(); }}
/>
{(search.q || search.sort) && (
<Button variant="ghost" onClick={clearSearch}>Clear filters</Button>
)}
</Toolbar>
{isRefetching && <LinearProgress />}
<Table>
<TableBody>
{list.map(contact => (
<TableRow key={contact._id}>
<TableCell>{contact.name}</TableCell>
<TableCell>{contact.email}</TableCell>
<TableCell>
<Button size="sm" onClick={() => navigate(
AppRoutes.contacts.detail.build({ params: { contactId: contact._id } })
)}>View</Button>
<Button size="sm" onClick={() => modal.openEditModal(contact)}>Edit</Button>
<Button size="sm" variant="destructive" loading={remove.isPending}
onClick={() => remove.mutate(contact._id)}>Delete</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Pagination
currentPage={pagination.page}
pageSize={pagination.limit}
total={pagination.totalDocuments}
onChange={(page, limit) => { onPaginationChange(page, limit); setSearch({ page }); }}
/>
<ContactModal
open={modal.isOpen}
mode={modal.data ? 'edit' : 'create'}
initialData={modal.data}
isSaving={create.isPending || update.isPending}
onSave={handleSave}
onClose={modal.closeModal}
/>
</>
);
}