Skip to content

feat: Generic CRUD hooks abstraction over ORPC procedures #175

@AliiiBenn

Description

@AliiiBenn

Problem

Currently each entity (employees, contracts, caces, etc.) has its own set of custom hooks:

// Multiple files per entity, each 60-80 lines of boilerplate
useEmployees.ts      // list + detail
useCreateEmployee.ts // mutation with optimistic update
useUpdateEmployee.ts // mutation with optimistic update  
useDeleteEmployee.ts // mutation with optimistic update

This creates:

  • Massive code duplication (~60-80 lines × 4 hooks × N entities)
  • Inconsistent patterns - some entities invalidate related queries, others don't
  • Maintenance burden - every new field requires updating multiple files
  • No type inference - lots of any and manual typing

Proposed Solution

1. Generic hooks working directly with ORPC procedures

// Instead of entity-specific hooks:
const { data } = useEmployee(123);           // useEmployees.ts
const create = useCreateEmployee();          // useCreateEmployee.ts

// Use generic hooks with ORPC procedures:
const { data } = useQuery(api.database.employees.get, { id: 123 });
const create = useMutation(api.database.employees.create);

// On page:
const { data: employees } = useQuery(api.database.employees.list);
create.mutate({ firstName: "John", lastName: "Doe" });

2. Cache Registry - Type-safe cache configuration per entity

A type encapsulating both query keys and mutation invalidation keys:

// Per-entity cache configuration
const employeeCache = {
  queries: {
    list: ['employees', 'list'] as const,
    detail: (id: number) => ['employees', 'detail', id] as const,
  },
  mutations: {
    create: {
      invalidate: ['employees', 'list', 'contracts', 'alerts'] as const,
      optimisticFn: (input) => { /* transform for optimistic update */ },
    },
    update: {
      invalidate: ['employees', 'list', 'employees.detail'] as const,
    },
    delete: {
      invalidate: ['employees', 'list', 'trash.deletedEmployees'] as const,
    },
  },
} satisfies CacheRegistry;

3. Hook integration

// useQuery with cache registry
const { data } = useQuery(api.database.employees.list, {
  cache: employeeCache.queries.list,
});

// useMutation with cache registry - invalidate and optimistic update
const create = useMutation(api.database.employees.create, {
  cache: employeeCache.mutations.create,
});

4. Hook Implementation Design

// Generic useQuery
export function useQuery<TProcedure extends ORPCProcedure>(
  procedure: TProcedure,
  input: InferInput<TProcedure>,
  options?: QueryOptions
) {
  const orpcReady = useORPCReady();
  return useQuery({
    queryKey: buildQueryKey(procedure, input),
    queryFn: () => procedure(input),
    enabled: orpcReady,
    ...options,
  });
}

// Generic useMutation with optimistic updates
export function useMutation<TProcedure extends ORPCProcedure>(
  procedure: TProcedure,
  options?: MutationOptions<TProcedure>
) {
  return useMutation({
    mutationFn: (input) => procedure(input),
    onMutate: async (input) => {
      await cancelQueries(buildQueryKey(procedure));
      const snapshot = snapshotQueries(buildQueryKey(procedure));
      // optimistic update using cache.optimisticFn...
      return { snapshot };
    },
    onError: (err, input, context) => {
      // rollback from snapshot...
      toast({ variant: "destructive", ... });
    },
    onSuccess: () => {
      // invalidate all keys from cache.invalidate...
      queryClient.invalidateQueries({ queryKey: buildQueryKey(procedure) });
    },
    ...options,
  });
}

5. Query Key Generation

Query keys derived from ORPC procedure paths:

// api.database.employees.list → ['employees', 'list']
// api.database.employees.get → ['employees', 'get', input.id]
// api.database.caces.create  → ['caces', 'create']

Tasks

  • Design generic useQuery and useMutation hooks
  • Create CacheRegistry type for entity cache configuration
  • Define QueryCacheKeys and MutationCacheKeys interfaces
  • Create buildQueryKey utility from ORPC procedure paths
  • Create CacheRegistry per entity (employees, contracts, caces, etc.)
  • Implement optimistic update support via optimisticFn
  • Integrate cache registry into generic hooks
  • Migrate existing pages to use new generic hooks
  • Remove entity-specific hook files
  • Ensure TypeScript inference works correctly
  • Add tests for new hooks

Benefits

  • DRY - Single implementation for all entities
  • Consistent - Same pattern everywhere, no ad-hoc variations
  • Type-safe - Types inferred from ORPC procedures
  • Maintainable - Adding a field only requires updating the page, not 4 hook files
  • Discoverable - API mirrors server structure (api.database.employees.*)
  • Centralized - Cache invalidation rules co-located per entity

Impact

  • Removes ~20 hook files of boilerplate code
  • Makes pages simpler and more readable
  • Aligns client API with server router structure
  • Clear separation between query cache keys and mutation invalidation keys

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions