46 lines · 1.5 KB
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "@tanstack/react-router";
import { errorMessage } from "~/lib/error-message";
import { paymentDetail, showUpsell } from "~/lib/upsell";

/**
 * Runs a server-function mutation, tracks its pending/error state and refreshes route loaders on
 * success. Route loaders are the source of truth for page data, so `router.invalidate({ sync: true })`
 * is the invalidation step; `sync` makes it wait for the loaders, so `pending` covers the refresh and
 * callers never see stale data after `run` resolves. Hooks that own react-query state invalidate
 * their own keys as well.
 */
export function useAction<TInput, TResult>(
  action: (input: TInput) => Promise<TResult>,
  options: { invalidate?: boolean } = {},
) {
  const router = useRouter();
  const invalidate = options.invalidate ?? true;
  const mutation = useMutation({
    mutationFn: action,
    onSuccess: async () => {
      if (invalidate) await router.invalidate({ sync: true });
    },
    // Out-of-budget errors open the upsell dialog everywhere, on top of the inline error message.
    onError: (error) => {
      const payment = paymentDetail(error);
      if (payment) showUpsell(payment);
    },
  });

  async function run(input: TInput): Promise<TResult | undefined> {
    try {
      return await mutation.mutateAsync(input);
    } catch {
      return undefined;
    }
  }

  return {
    run,