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