@playlive/react-query
    Preparing search index...

    @playlive/react-query

    @playlive/react-query

    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

    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.

    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.

    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".

    @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:

    • Cache sharing + dedupe — hooks that build the same queryKey share one cache entry and one in-flight request. useCampaigns rows de-dupe against sibling useCampaign consumers for free.
    • Cursor paginationuseInfiniteDonations / useTiltifyLeaderboard sit on useInfiniteQuery; the dependency-free tier has no equivalent.
    • Mutations with invalidationuseLeaderboardExclusions invalidates its own read on a successful add/remove, so the list updates without waiting for a poll.
    • Devtools — every query is a plain TanStack query, so @tanstack/react-query-devtools can inspect them. This package does not bundle or re-export devtools; add them yourself if you want them.
    • 26 extra hooks — leaderboards, donation trains, alerts, and the Play Live first-party services only exist in this tier (see the API reference).

    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 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.

    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
    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 }

    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.

    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.

    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.
    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.
    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.

    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.

    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/

    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.

    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.

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

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

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

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

    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.

    MIT © St. Jude Children's Research Hospital