Files

8.2 KiB

Hook Development Patterns

This document covers React hook patterns for data fetching, mutations, and state management using React Query with oRPC.

Query Hooks

Basic Query Pattern

import { useQuery } from '@tanstack/react-query';
import { orpcClient } from '@/lib/orpc';

export function useUsers() {
  return useQuery({
    queryKey: ['users'],
    queryFn: () => orpcClient.users.list(),
  });
}

Query with Parameters

export function useUser(userId: string) {
  return useQuery({
    queryKey: ['users', userId],
    queryFn: () => orpcClient.users.get({ id: userId }),
    enabled: !!userId, // Only fetch when userId is available
  });
}

Query with Filters

interface UseOrdersOptions {
  status?: string;
  page?: number;
  pageSize?: number;
}

export function useOrders(options: UseOrdersOptions = {}) {
  const { status, page = 1, pageSize = 20 } = options;

  return useQuery({
    queryKey: ['orders', { status, page, pageSize }],
    queryFn: () => orpcClient.orders.list({ status, page, pageSize }),
    placeholderData: (previousData) => previousData, // Keep previous data while fetching
  });
}

Mutation Hooks

Basic Mutation Pattern

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { orpcClient } from '@/lib/orpc';

export function useCreateUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (data: CreateUserInput) => orpcClient.users.create(data),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });
}

Mutation with Optimistic Updates

type OrderListData = Awaited<ReturnType<typeof orpcClient.orders.list>>;

export function useUpdateOrderStatus() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ id, status }: { id: string; status: string }) =>
      orpcClient.orders.updateStatus({ id, status }),

    onMutate: async ({ id, status }) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: ['orders'] });

      // Snapshot previous value
      const previousOrders = queryClient.getQueryData<OrderListData>(['orders']);

      // Optimistically update
      queryClient.setQueryData<OrderListData>(['orders'], (old) => {
        if (!old) return old;
        return {
          ...old,
          items: old.items.map((order) =>
            order.id === id ? { ...order, status } : order
          ),
        };
      });

      return { previousOrders };
    },

    onError: (_err, _variables, context) => {
      // Rollback on error
      if (context?.previousOrders) {
        queryClient.setQueryData(['orders'], context.previousOrders);
      }
    },

    onSettled: () => {
      // Always refetch after mutation
      queryClient.invalidateQueries({ queryKey: ['orders'] });
    },
  });
}

Overriding Mutation Callbacks

When overriding mutation callbacks at the call site, you MUST add explicit generics to maintain type safety:

Problem: Lost Type Safety

// Hook definition
export function useDeleteUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (id: string) => orpcClient.users.delete({ id }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });
}

// Bad: Overriding without generics loses type safety
const deleteUser = useDeleteUser();
deleteUser.mutate(userId, {
  onSuccess: (data) => {
    // 'data' is typed as 'unknown' here!
    console.log(data.id); // TypeScript error or runtime error
  },
});

Solution: Explicit Generics

// Infer types for the mutation
type DeleteUserData = Awaited<ReturnType<typeof orpcClient.users.delete>>;
type DeleteUserVariables = string;

// Good: Add explicit generics when overriding callbacks
deleteUser.mutate<DeleteUserData, Error, DeleteUserVariables>(userId, {
  onSuccess: (data) => {
    // 'data' is properly typed
    console.log(data.id); // Works correctly
  },
});

Alternative: Define Types in Hook

// Export types from the hook file
export type DeleteUserMutationData = Awaited<
  ReturnType<typeof orpcClient.users.delete>
>;

// Usage with exported types
deleteUser.mutate(userId, {
  onSuccess: (data: DeleteUserMutationData) => {
    console.log(data.id);
  },
});

Using orpcClient Directly in Hooks

Inside hooks, use orpcClient directly instead of wrapping with useMutation:

DO: Direct orpcClient Usage

export function useOrderActions() {
  const queryClient = useQueryClient();

  const updateOrder = useMutation({
    mutationFn: (data: UpdateOrderInput) => orpcClient.orders.update(data),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['orders'] });
    },
  });

  const deleteOrder = useMutation({
    mutationFn: (id: string) => orpcClient.orders.delete({ id }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['orders'] });
    },
  });

  return {
    updateOrder: updateOrder.mutate,
    deleteOrder: deleteOrder.mutate,
    isUpdating: updateOrder.isPending,
    isDeleting: deleteOrder.isPending,
  };
}

DON'T: Nested Hooks

// Bad: Don't create hooks that use other mutation hooks
export function useOrderActions() {
  // Don't do this - creates unnecessary abstraction
  const updateMutation = useUpdateOrder();
  const deleteMutation = useDeleteOrder();

  return {
    updateOrder: updateMutation.mutate,
    deleteOrder: deleteMutation.mutate,
  };
}

Compound Hooks

Combine related queries and mutations into a single hook:

export function useProduct(productId: string) {
  const queryClient = useQueryClient();

  const query = useQuery({
    queryKey: ['products', productId],
    queryFn: () => orpcClient.products.get({ id: productId }),
    enabled: !!productId,
  });

  const update = useMutation({
    mutationFn: (data: UpdateProductInput) =>
      orpcClient.products.update({ id: productId, ...data }),
    onSuccess: (updatedProduct) => {
      queryClient.setQueryData(['products', productId], updatedProduct);
    },
  });

  const remove = useMutation({
    mutationFn: () => orpcClient.products.delete({ id: productId }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['products'] });
    },
  });

  return {
    product: query.data,
    isLoading: query.isLoading,
    error: query.error,
    updateProduct: update.mutate,
    deleteProduct: remove.mutate,
    isUpdating: update.isPending,
    isDeleting: remove.isPending,
  };
}

Infinite Query Pattern

export function useInfiniteOrders() {
  return useInfiniteQuery({
    queryKey: ['orders', 'infinite'],
    queryFn: ({ pageParam = 1 }) =>
      orpcClient.orders.list({ page: pageParam, pageSize: 20 }),
    getNextPageParam: (lastPage) =>
      lastPage.hasMore ? lastPage.page + 1 : undefined,
    initialPageParam: 1,
  });
}

Dependent Queries

export function useUserOrders(userId: string) {
  // First query: get user
  const userQuery = useQuery({
    queryKey: ['users', userId],
    queryFn: () => orpcClient.users.get({ id: userId }),
    enabled: !!userId,
  });

  // Second query: depends on user data
  const ordersQuery = useQuery({
    queryKey: ['orders', { userId }],
    queryFn: () => orpcClient.orders.list({ userId }),
    enabled: !!userQuery.data, // Only run when user is loaded
  });

  return {
    user: userQuery.data,
    orders: ordersQuery.data,
    isLoading: userQuery.isLoading || ordersQuery.isLoading,
  };
}

Best Practices

  1. Single Responsibility: Each hook should have one clear purpose
  2. Consistent Naming: useXxx for hooks, useXxxQuery for queries, useXxxMutation for mutations
  3. Error Handling: Always consider error states in your hooks
  4. Loading States: Expose loading states for UI feedback
  5. Cache Keys: Use consistent, hierarchical query keys
  6. Type Safety: Always maintain proper TypeScript types

Common Pitfalls

  • Forgetting to invalidate related queries after mutations
  • Not handling race conditions with cancelQueries
  • Missing enabled flag for conditional queries
  • Not providing explicit generics when overriding callbacks
  • Creating too many small hooks instead of compound hooks