← all packages

@playlive/react-query

v0.4.4

npm install @playlive/react-query

Requires @playlive:registry=https://packages.playlive.experience.stjude.org in your .npmrc. packument · tarball · API docs


TanStack Query hooks over @playlive/fundraiser-data — campaigns, donations, milestones, leaderboards, donation trains, and the Play Live first-party services, each wrapped in a useQuery / useInfiniteQuery / useMutation with workspace-tuned defaults. The 19 hooks that also exist in @playlive/react-data keep identical names, parameter shapes, and return shapes, so those call sites swap tiers with a single import rewrite.

Coverage

Install

bun add @playlive/react-query @playlive/fundraiser-data @playlive/tiltify-core @tanstack/react-query react

All four are peer dependencies (jose-style — the consumer brings their own). None are optional; package.json declares no peerDependenciesMeta:

Peer Range Notes
react ^19.0.0 Hooks only — no react-dom, nothing here renders.
@tanstack/react-query ^5.0.0 v5 API (isPending, initialPageParam, object-form useQuery). v4 will not work.
@playlive/fundraiser-data workspace:* Every fetcher this package wraps. Must be configure()d at boot.
@playlive/tiltify-core workspace:* Type-only at runtime — the hooks module imports it under import type, so nothing of it ships in the emitted bundle. Still a required peer because the emitted .d.ts references TiltifyCampaign, TiltifyDonation, etc.

dependencies is empty. sideEffects: false, so unused hooks tree-shake out.

Quick start

Three steps: configure() the data layer, build a QueryClient, mount the provider.

import { QueryClientProvider } from "@tanstack/react-query";
import { configure } from "@playlive/fundraiser-data";
import { makeQueryClient, useCampaign, useCampaignAmounts } from "@playlive/react-query";

// 1. Point the data layer at the Tiltify proxy (once, at module scope).
configure({ tiltifyProxyUrl: "https://api.experience.stjude.org/tiltify" });

// 2. A QueryClient pre-seeded with the workspace defaults
//    (5.1 s staleTime, 5 s polling, 10 retries — see the table below).
const qc = makeQueryClient();

// 3. Provide it above every component that calls a hook.
export function App() {
  return (
    <QueryClientProvider client={qc}>
      <ProgressBar campaignId="demo-campaign-a" />
    </QueryClientProvider>
  );
}

function ProgressBar({ campaignId }: { campaignId: string }) {
  const campaign = useCampaign({ charityType: "tiltify", id: campaignId });
  const { totalAmount, goalAmount } = useCampaignAmounts(campaign.data);

  if (campaign.isPending) return <p>Loading…</p>;
  if (campaign.error) return <p>Error: {campaign.error.message}</p>;
  if (!campaign.data) return <p>No campaign.</p>;

  const percent = goalAmount > 0 ? (totalAmount / goalAmount) * 100 : 0;
  return (
    <figure>
      <figcaption>{campaign.data.name}</figcaption>
      <progress value={totalAmount} max={goalAmount} />
      <span>{percent.toFixed(1)}%</span>
    </figure>
  );
}

configure() is re-exported from both the @playlive/fundraiser-data barrel and its /config subpath. tiltifyProxyUrl is the only required field; twitchServiceUrl, causeId, scheduleApiUrl, lifetimeApiUrl, leaderboardApiUrl, donorSpotlightApiUrl, and donationTrainApiUrl are optional and gate the hooks that talk to those services.

isPending vs isLoading

Both are passed straight through from TanStack Query v5, and they differ in exactly one case that matters here: a hook whose id is nullish is disabled, which leaves isPending: true forever while isLoading is false. Branch on isPending when you want "we have neither data nor an error yet"; branch on isLoading when you want "a first fetch is actually in flight".

How this differs from @playlive/react-data

@playlive/react-data implements its hooks on a single useFetch primitive built from useState / useRef — the state lives inside the calling component. Two components asking for the same campaign issue two independent requests and hold two copies. This package hands the same work to a QueryClient, which buys:

The cost is the @tanstack/react-query peer in your bundle. If you only need the 19 shared hooks and want the smaller graph, use @playlive/react-data.

Subpath exports

Subpath Contents
@playlive/react-query Default barrel — re-exports /config, every hook, /types, plus PACKAGE_NAME and KNOWN_URLS.
@playlive/react-query/config DEFAULT_QUERY_OPTIONS + makeQueryClient.
@playlive/react-query/types UseFetchOptions, UseFetchResult, UseInfiniteDonationsResult, HookRefetchOptions.

That is the complete exports map — there is no /hooks subpath; import hooks from the barrel. Each entry ships an ESM bundle, a bun source condition pointing at src/, and .d.ts declarations.

API reference

Query hooks — standard UseFetchResult<T>

Every row takes (params, options?: UseFetchOptions) and returns UseFetchResult<T> ({ data, error, isLoading, isPending, isFetching, refetch }). "Auto-disabled" lists the guard that flips enabled to false.

Hook data Auto-disabled when
useCampaign TiltifyCampaign | TiltifyPersonalCampaign | TiltifyTeamCampaign | null never
useFlattenedDonations TiltifyDonation[] campaignId nullish
useMilestones TiltifyMilestone[] campaignId nullish
useRewards TiltifyReward[] campaignId nullish
usePolls TiltifyPoll[] campaignId nullish
useTargets TiltifyTarget[] campaignId nullish
useSchedule TiltifySchedule[] campaignId nullish
useUser TiltifyUser | null userSlug empty
useTeam TiltifyTeam | null teamSlug empty
useFundraisingEvent TiltifyFundraisingEvent | null eventId nullish
useFundraisingEventMilestones TiltifyFactMilestone[] eventId nullish
useCause TiltifyCause | null causeId nullish
useEventCampaigns TiltifyCampaign[] eventId nullish
useCurrentEvents TiltifyFundraisingEvent[] never
useTiltifyUserCampaigns TiltifyPersonalCampaign[] userId nullish, empty, or the string "null"
useTiltifyUserAndTeamCampaigns (TiltifyPersonalCampaign | TiltifyTeamCampaign)[] userId nullish, empty, or the string "null"
useScheduleBlockRaised ScheduleBlockRaised | undefined any of campaignId / start / end nullish
useLifetimeRaised number | null username nullish or empty
usePreviousYearTotals PreviousYearTotalItem[] slug nullish or empty
useGiftsThatGiveMilestones GiftsThatGiveMilestone[] goal nullish
useDonorSpotlightOverview DonorSpotlightSnapshot | null campaignId nullish or empty
useLeaderboardWithExclusions MonetaryLeaderboardEntry[] campaignID nullish or empty
useDonationTrains DonationTrain[] campaignID nullish or empty
useDonationTrainHighRateDonors DonationTrainHighRateDonor[] campaignID nullish or empty
useCampaignRulesets DonationTrainRuleset[] campaignID nullish or empty
useDonationTrainCommonTrains CommonDonationTrain[] campaignID nullish or empty

useRewards and useTargets additionally accept sort?: boolean in params. useFundraisingEventMilestones takes an optional charityType that defaults to "tiltify". The donation-train and leaderboard-exclusion hooks take (params, authAndOptions?) where the second argument is DonationTrainAuthOptions & UseFetchOptions (or LeaderboardAuthOptions & UseFetchOptions) — adminApiKey / tiltifyOAuthToken are split out and never reach TanStack.

Twitch-unsupported entities (useMilestones, useRewards, usePolls, useTargets, useSchedule, useUser, useTeam, useFundraisingEvent, useFundraisingEventMilestones, useCause, useEventCampaigns, useLeaderboardWithExclusions) resolve to [] / null on the charityType: "twitch" path rather than throwing — same lenient semantics as the underlying fetchers.

Per-hook cadence overrides. Some hooks override DEFAULT_QUERY_OPTIONS because their upstream data doesn't change every five seconds. Pass options.refetchInterval / options.staleTime to win back control:

Hook staleTime refetchInterval
useTiltifyUserCampaigns, useTiltifyUserAndTeamCampaigns Infinity false
useScheduleBlockRaised (also refetchOnReconnect: false) Infinity false
useLifetimeRaised 5_100 60_000
usePreviousYearTotals 5_100 5 * 60_000
useDonationTrains, useCampaignRulesets 5_100 / 5_000 15_000
useDonationTrainHighRateDonors, useDonationTrainCommonTrains 5_100 25_000

Query hooks with a custom return shape

Hook Returns
useCampaigns UseCampaignsResult{ data[], errors[], isLoading, isPending, isFetching, isError, refetch }, ordered 1-for-1 with the params array. Built on useQueries + combine. Rows are never auto-disabled; a row with no id still fires.
useInfiniteDonations UseInfiniteDonationsResult<PaginatedResponse<TiltifyDonation, TiltifyPaginationMetadata | TwitchPaginationMetadata>>
useTiltifyLeaderboard UseTiltifyLeaderboardResult{ entries, data, error, isLoading, isPending, isFetching, isFetchingNextPage, hasNextPage, fetchNextPage, refetch }. Forward-only; no fetchPreviousPage.
useLeaderboardExclusions UseLeaderboardExclusionsResult{ data, donorNames, error, isLoading, isPending, isFetching, isMutating, addExclusion, removeExclusion, refetch }
useLeaderboard UseLeaderboardResult{ leaderboard, donations, currentTotal, exclusions, isLoadingExclusions, isLoadingDonations, isFetching, isFetchingNextPage, hasNextPage, error }. Note: no isLoading, no refetch.
useCampaignGiftsThatGiveMilestones { giftMilestones: GiftsThatGiveMilestoneItem[]; isLoading: boolean }
useDonationTrainState UseDonationTrainStateResult{ trains, updateTrainStatus, updateTrainVisibility, highRateDonors, commonTrains, isLoading }
useCampaignRulesetsState UseCampaignRulesetsStateResult{ rulesets, updateRuleset, deleteRuleset, createRuleset }

Non-query hooks (pure client-side)

These issue no requests — they're useMemo / useState / setTimeout helpers that happen to live here so overlays can import one package.

Hook Signature
useCampaignAmounts (campaignOrFundraiser: CampaignLike | null | undefined) => CampaignAmounts — reference-stable { totalAmount, currentAmount, goalAmount, originalGoalAmount, supportingAmount }.
useCampaignFundraisingEventAmounts (campaign, fundraisingEvent, options?: { forceCampaignGoal?: boolean }) => CampaignFundraisingEventAmounts
useDonationsReducer (donationsData: DonationPagesInput | null | undefined, flipSorting = false, onlyReturnNew = false) => TiltifyDonation[] — flattens a { pages, pageParams } envelope; sticky-accumulates by default so rows survive maxPages trimming.
useAlertsQueue (params: UseAlertsQueueParams, options?: UseAlertsQueueOptions) => { donation: TiltifyDonation | null } — display-timing loop over an external donation queue.

Mutation hooks

Each returns UseMutationResult<TData, TVariables>{ mutate, mutateAsync, data, error, isPending, reset }, a projection of TanStack's useMutation so consumers don't need TanStack's types.

Hook Argument TVariables TData
useUpdateTrainVisibility auth? { trainID: string; trainVisible: boolean } DonationTrain | null
useRefreshTrainStatus { trainID: string } DonationTrain | null
useProcessDonationsForTrains { donations: TiltifyDonation[] } DonationTrain[]
useCreateCampaignRuleset auth? { campaignID: string; ruleset: PartialDonationTrainRuleset } DonationTrainRuleset | null
useUpdateRuleset auth? { ruleset: DonationTrainRuleset; campaignID?: string } DonationTrainRuleset
useDeleteRuleset auth? { rulesetID: string; campaignID?: string } { id: string } | null
useTestDonations auth? TiltifyDonation | TiltifyDonation[] void

auth is DonationTrainAuthOptions (TestDonationAuthOptions for useTestDonations) — pass adminApiKey or tiltifyOAuthToken. useUpdateRuleset / useDeleteRuleset pick their route from the presence of campaignID: with it, the campaign-scoped route (admin key or OAuth); without it, the admin-only legacy route.

useTestDonations fires a synthetic donation through the core REST API (POST /donations/tiltify/test), which replays it down the same webhook path a real donation takes — alerts, donation trains, the subathon timer, and every WebSocket subscriber react as if Tiltify had delivered it. Demo campaigns skip auth entirely.

Constants

Export Kind Value
PACKAGE_NAME const "@playlive/react-query" — for runtime version-pinning checks.
KNOWN_URLS const readonly string[], frozen and empty. See the URL-disclosure section.
DEFAULT_QUERY_OPTIONS const Workspace query defaults (table below). Also on /config.
DEFAULT_ALERT_DURATIONS_MS const Readonly<Record<DonorLevel, number>>platinum 9500, gold 8500, silver 7500, bronze 6500, grey 5500. Merged under useAlertsQueue's durations override.

Functions

Export Kind Signature
makeQueryClient function (overrides?: QueryClientConfig) => QueryClient — builds a client whose defaultOptions.queries is DEFAULT_QUERY_OPTIONS with overrides.defaultOptions.queries merged on top.

Common options (UseFetchOptions)

Option Default Description
enabled true Skip fetching when false. ANDed with the hook's own nullish guard.
refetchInterval 5_000 Poll every N ms. Pass false to disable polling.
retry 10 Retries on error. Pass false (or 0) in tests, or for hooks whose id may legitimately 404.
retryDelay 1_000 Base delay (ms); TanStack applies exponential backoff on top.
staleTime 5_100 Freshness window. Pass 0 to make every read refetch.
initialData Seed the query (TanStack initialData) so a route loader's payload paints immediately. Typed unknown; cast at the call site. For useCampaigns an array seeds rows 1:1 against params, a scalar seeds every row.
maxPages Retained-page cap. Honoured only by useInfiniteDonations and useTiltifyLeaderboard; every other hook ignores it. TanStack v5 drops the oldest page at the limit.
cachingEnabled Boolean cache partition. Contributes to the queryKey of useInfiniteDonations / useTiltifyLeaderboard only — the fetcher is unchanged.

initialData, maxPages, and cachingEnabled are stripped from the merged options bag before it reaches TanStack and re-applied per hook with a narrow cast; without that, initialData?: unknown would collapse TQueryFnData inference to unknown everywhere.

Workspace defaults (DEFAULT_QUERY_OPTIONS)

Every hook merges these in as base defaults (caller options wins), and makeQueryClient() installs the same object as the client's defaultOptions.queries.

Option Value Rationale
staleTime 5_100 Matches overlay-data-layer's existing freshness window.
refetchInterval 5_000 Sane polling for live overlays.
retry 10 Flaky stream-conf networks; backoff protects the proxy.
retryDelay 1_000 Base for TanStack's exponential backoff schedule.
refetchOnWindowFocus false OBS browser sources have no meaningful focus events.
refetchOnReconnect true Recovery after a network blip is the right live semantic.

Result types

Exported from the barrel and from @playlive/react-query/types:

interface UseFetchResult<T> {
  data: T | undefined;
  error: Error | null;
  isLoading: boolean;
  isPending: boolean;
  isFetching: boolean;
  refetch: (options?: HookRefetchOptions) => Promise<void>;
}

interface UseInfiniteDonationsResult<TPage> {
  data: { pages: TPage[]; pageParams: Array<string | number | null | undefined> } | undefined;
  error: Error | null;
  isLoading: boolean;
  isPending: boolean;
  isFetching: boolean;
  isFetchingNextPage: boolean;
  isFetchingPreviousPage: boolean;
  hasNextPage: boolean;
  hasPreviousPage: boolean;
  fetchNextPage: () => Promise<void>;
  fetchPreviousPage: () => Promise<void>;
  refetch: (options?: HookRefetchOptions) => Promise<void>;
}

/** Structural subset of TanStack v5's `RefetchOptions`. */
interface HookRefetchOptions {
  cancelRefetch?: boolean; // default true — cancels an in-flight fetch first
}

refetch is wrapped in a useCallback keyed on TanStack's own refetch, so its identity is stable across renders — safe to list in a useEffect dep array without looping.

Every hook also exports its Use…Params / Use…Result interface (UseCampaignParams, UseInfiniteDonationsParams, UseLeaderboardParams, LeaderboardRow, GiftsThatGiveMilestoneItem, UseMutationResult, …) from the barrel. Domain payload types (TiltifyDonation, DonationTrain, MonetaryLeaderboardEntry, ScheduleBlockRaised, …) are not re-exported — import them from @playlive/tiltify-core and @playlive/fundraiser-data.

Full generated API documentation: https://packages.playlive.experience.stjude.org/p/@playlive/react-query/docs/

Upstream spec

No external API surface of its own. Every hook wraps a fetcher from @playlive/fundraiser-data, so the endpoints, authentication, and URL knobs are entirely that package's — configure() it once at boot and these hooks inherit whatever you pointed it at (the Tiltify proxy, the Twitch charity service, and the Play Live schedule / lifetime-raised / leaderboard / donor-spotlight / donation-train services). See that package's README for the full list, and Tiltify's public developer documentation at https://developers.tiltify.com for the upstream resource shapes.

Twitch Extension URL disclosure

The KNOWN_URLS export enumerates every absolute URL or host this package can fetch. It is empty — this package hardcodes no production hosts; every request goes through a @playlive/fundraiser-data fetcher pointed at a URL you supplied to configure().

import { KNOWN_URLS } from "@playlive/react-query";

console.log(KNOWN_URLS); // []

For an Extension submission, disclose @playlive/fundraiser-data's URLs plus whatever you passed for tiltifyProxyUrl, twitchServiceUrl, and the Play Live service URLs.

Examples

Full overlay — provider, config, three hooks, all three states

import { QueryClientProvider } from "@tanstack/react-query";
import { configure } from "@playlive/fundraiser-data";
import {
  makeQueryClient,
  useCampaign,
  useCampaignAmounts,
  useMilestones,
  useLeaderboard,
} from "@playlive/react-query";

configure({
  tiltifyProxyUrl: "https://api.experience.stjude.org/tiltify",
  leaderboardApiUrl: "https://api.experience.stjude.org/leaderboard",
});

const queryClient = makeQueryClient();

export function OverlayRoot({ campaignId }: { campaignId: string }) {
  return (
    <QueryClientProvider client={queryClient}>
      <Overlay campaignId={campaignId} />
    </QueryClientProvider>
  );
}

function Overlay({ campaignId }: { campaignId: string }) {
  const campaign = useCampaign({ charityType: "tiltify", id: campaignId });
  const milestones = useMilestones({ charityType: "tiltify", campaignId });

  // Composes useInfiniteDonations + useLeaderboardExclusions; `eagerFetchPages`
  // (default true) walks the cursor so the ranking sees every donation.
  const { leaderboard, currentTotal, isLoadingDonations } = useLeaderboard({
    charityType: "tiltify",
    campaignId,
    limit: 5,
    removeAnonymous: true,
  });

  const { totalAmount, goalAmount } = useCampaignAmounts(campaign.data);

  if (campaign.isPending) return <p>Connecting…</p>;
  if (campaign.error) return <p role="alert">Campaign failed: {campaign.error.message}</p>;
  if (!campaign.data) return <p>Campaign not found.</p>;

  return (
    <section>
      <h1>{campaign.data.name}</h1>
      <progress value={totalAmount} max={goalAmount} />

      <ul>
        {(milestones.data ?? [])
          .filter((m) => m.active)
          .map((m) => (
            <li key={m.id}>
              {m.name} — ${m.amount.value}
            </li>
          ))}
      </ul>

      <h2>Top donors (${currentTotal.toFixed(2)} raised)</h2>
      {isLoadingDonations ? (
        <p>Tallying…</p>
      ) : (
        <ol>
          {leaderboard.map((row) => (
            <li key={row.id}>
              {row.donor_name} — ${row.amount.toFixed(2)}
            </li>
          ))}
        </ol>
      )}
    </section>
  );
}

Dependent query + polling override

cause_id lives on every campaign shape, so the cause lookup can chain off the campaign result. Passing undefined for causeId keeps useCause disabled (and therefore isPending) until the first campaign resolves. The cause record is static reference data, so polling is switched off for it.

import { useCampaign, useCause } from "@playlive/react-query";

function CauseBadge({ campaignId }: { campaignId: string }) {
  const campaign = useCampaign({ charityType: "tiltify", id: campaignId });

  const cause = useCause(
    { charityType: "tiltify", causeId: campaign.data?.cause_id },
    { refetchInterval: false, staleTime: Number.POSITIVE_INFINITY },
  );

  if (!cause.data) return null;
  return <img src={cause.data.avatar?.src} alt={cause.data.name} />;
}

Infinite donation feed

useInfiniteDonations is the tier-exclusive cursor hook. Tiltify pages on an opaque string cursor (metadata.after), Twitch on a numeric page index (metadata.nextPage); getNextPageParam reads both, so charityType can be a runtime value. Only Tiltify exposes a reverse cursor (metadata.before), so hasPreviousPage is permanently false on the Twitch path and fetchPreviousPage no-ops there.

useDonationsReducer flattens the { pages, pageParams } envelope into a sorted array and — with onlyReturnNew left at false — keeps rows that maxPages has already trimmed out of the cache, which is what a scrolling column wants.

import { useEffect } from "react";
import { useDonationsReducer, useInfiniteDonations } from "@playlive/react-query";

function DonationFeed({ campaignId }: { campaignId: string }) {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isPending,
    error,
  } = useInfiniteDonations(
    { charityType: "tiltify", campaignId, count: 50 },
    { maxPages: 5 },
  );

  const donations = useDonationsReducer(data);

  // Walk the cursor to the end once, then let the 5 s poll pick up new rows.
  useEffect(() => {
    if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
  }, [hasNextPage, isFetchingNextPage, fetchNextPage]);

  if (isPending) return <p>Loading donations…</p>;
  if (error) return <p role="alert">{error.message}</p>;

  return (
    <ul>
      {donations.map((d) => (
        <li key={d.id}>
          {d.donor_name || "Anonymous"} — ${d.amount.value}
        </li>
      ))}
    </ul>
  );
}

Moderating the exclusion list

The read (GET /leaderboard-exclusions/{id}) is public; the POST / DELETE behind addExclusion / removeExclusion need adminApiKey (sent as x-api-key) or tiltifyOAuthToken (sent as Authorization: OAuth <token>). Auth keys are split out of the second argument before the rest forwards to TanStack, and a successful mutation invalidates the read so the list refreshes without waiting for a poll.

import { useLeaderboardExclusions } from "@playlive/react-query";

function ExclusionEditor({ campaignID, token }: { campaignID: string; token: string }) {
  const { donorNames, isMutating, addExclusion, removeExclusion } = useLeaderboardExclusions(
    { campaignID },
    { tiltifyOAuthToken: token, refetchInterval: 30_000 },
  );

  return (
    <ul>
      {donorNames.map((name) => (
        <li key={name}>
          {name}
          <button type="button" disabled={isMutating} onClick={() => void removeExclusion(name)}>
            Remove
          </button>
        </li>
      ))}
      <li>
        <button type="button" disabled={isMutating} onClick={() => void addExclusion("Bob")}>
          Exclude Bob
        </button>
      </li>
    </ul>
  );
}

Swapping tiers

The 19 hooks below exist in both packages with identical params and identical UseFetchResult returns, so migrating those call sites is one import rewrite:

-import { useCampaign, useMilestones } from "@playlive/react-query";
+import { useCampaign, useMilestones } from "@playlive/react-data";

useCampaign, useCampaigns, useCause, useEventCampaigns, useFlattenedDonations, useFundraisingEvent, useGiftsThatGiveMilestones, useLifetimeRaised, useMilestones, usePolls, usePreviousYearTotals, useRewards, useSchedule, useScheduleBlockRaised, useTargets, useTeam, useTiltifyUserAndTeamCampaigns, useTiltifyUserCampaigns, useUser.

Drop <QueryClientProvider> if nothing else in the tree needs it. Everything outside that list — infinite pagination, leaderboards, donation trains, mutations, alerts, and the Play Live first-party services — is react-query-only; those call sites have to stay on this tier. See @playlive/react-pipeline for the fusion hooks that layer live WebSocket updates over these REST baselines.

License

MIT © St. Jude Children's Research Hospital