← all packages

@playlive/react-data

v0.3.2

npm install @playlive/react-data

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


Minimal React hooks over @playlive/fundraiser-data. Drop-in compatible with @playlive/react-query — same hook names, same parameter shape, same { data, error, isLoading, isPending, isFetching, refetch } return.

No TanStack Query, no Zustand, no react-use-websocket-lite. Built on useState + useEffect + AbortController with optional polling and exponential-backoff retry. Twitch-Extension safe.

Coverage

Install

bun add @playlive/react-data

Every dependency is a required peer (jose-style — the consumer brings its own copy so wire types stay in lockstep). npm 7+ / Bun auto-install them, so the one-liner above is usually enough:

Peer Range Why
react ^19.0.0 useState / useEffect / useRef / useCallback.
@playlive/fundraiser-data * Every hook delegates to its fetchers, and you call its configure().
@playlive/tiltify-core * Hook return types reference the Tiltify domain types.

To pin them explicitly:

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

@playlive/tiltify-core is type-only here — the value imports are erased at compile time, so nothing of it lands in this package's bundle. It still has to be installed because @playlive/fundraiser-data requires it at runtime anyway.

No react-dom — these hooks render nothing. No other dependencies.

Quick start

import { configure } from "@playlive/fundraiser-data/config";
import { useCampaign, useFlattenedDonations, useMilestones } from "@playlive/react-data";

// Configure fundraiser-data once at app boot, before the first render.
configure({ tiltifyProxyUrl: "https://tiltify-proxy.prod.experience.stjude.org" });

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

  if (campaign.isLoading) return <p>Loading…</p>;
  if (campaign.error) return <p>Error: {campaign.error.message}</p>;

  return (
    <pre>
      {JSON.stringify(
        { campaign: campaign.data, donations: donations.data, milestones: milestones.data },
        null,
        2,
      )}
    </pre>
  );
}

Swapping in TanStack Query later

Every hook in this package has an API-compatible counterpart in @playlive/react-query. Migration is a single import rewrite:

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

Same params. Same return shape. The behavioral differences: TanStack Query adds cache sharing across components, request deduplication, and background refetch-on-focus — and its tier honours the pass-through options this tier ignores (staleTime, initialData, maxPages, cachingEnabled). It also ships hooks with no counterpart here (useInfiniteDonations, useAlertsQueue, useLeaderboard, the donation-train mutations, …).

Subpath exports

Subpath Description
@playlive/react-data Default barrel — every hook, the useFetch primitive, the shared types, plus PACKAGE_NAME + KNOWN_URLS.
@playlive/react-data/core The useFetch primitive + its Fetcher<T> type (build your own domain hooks).
@playlive/react-data/types Type-only: UseFetchResult<T> + UseFetchOptions.

Each subpath ships an ESM bundle, a Bun source condition, and .d.ts declarations. Tree-shaking removes unused exports from the consumer's bundle.

API reference

Campaign + Tiltify entity hooks

Every hook takes (params, options?) where options is UseFetchOptions, and returns UseFetchResult<T>.

Hook Params data Auto-disabled when
useCampaign { charityType, id?, slug?, teamUserSlug?, isTeam? } TiltifyCampaign | TiltifyPersonalCampaign | TiltifyTeamCampaign | null never (always enabled)
useCampaigns UseCampaignParams[] Array<…Campaign | null> — one Promise.all batch never (always enabled)
useFlattenedDonations { campaignId, isTeam?, config?, count?, maxPages? } TiltifyDonation[] campaignId is nullish
useMilestones { charityType, campaignId, isTeam? } TiltifyMilestone[] campaignId is nullish
useRewards { charityType, campaignId, isTeam?, sort? } TiltifyReward[] campaignId is nullish
usePolls { charityType, campaignId, isTeam? } TiltifyPoll[] campaignId is nullish
useTargets { charityType, campaignId, isTeam?, sort? } TiltifyTarget[] campaignId is nullish
useSchedule { charityType, campaignId, isTeam? } TiltifySchedule[] campaignId is nullish
useUser { charityType, userSlug } TiltifyUser | null userSlug is empty
useTeam { charityType, teamSlug } TiltifyTeam | null teamSlug is empty
useFundraisingEvent { charityType, eventId } TiltifyFundraisingEvent | null eventId is nullish
useCause { charityType, causeId } TiltifyCause | null causeId is nullish
useEventCampaigns { charityType, eventId } TiltifyCampaign[] eventId is nullish
useTiltifyUserCampaigns { userId } (Tiltify user UUID) TiltifyPersonalCampaign[] userId nullish / "" / "null"
useTiltifyUserAndTeamCampaigns { userId } (TiltifyPersonalCampaign | TiltifyTeamCampaign)[] userId nullish / "" / "null"

useCampaigns is batch-semantic: one underlying request fires Promise.all(params.map(fetchCampaign)), so a partial failure fails the whole batch (error set, data undefined). @playlive/react-query's richer useCampaigns runs one query per row and exposes per-row errors.

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

Play Live first-party service hooks

These reach the Play Live first-party services, so the matching URL must be present in configure() (or come from a @playlive/fundraiser-data/environments preset).

Hook Params data Default polling Auto-disabled when Needs config
useScheduleBlockRaised { campaignId, start, end } ScheduleBlockRaised | null off any of campaignId / start / end is nullish scheduleApiUrl
useLifetimeRaised { username, isTeam? } number | null 60_000 ms username is nullish lifetimeApiUrl
usePreviousYearTotals { slug, isTeam? } PreviousYearTotalItem[] 300_000 ms slug is nullish lifetimeApiUrl
useGiftsThatGiveMilestones { goal } (GiftsThatGiveMilestoneGoal | number) GiftsThatGiveMilestone[] off goal is nullish lifetimeApiUrl

Caller options are spread after the built-in defaults, so passing { refetchInterval: 0 } switches polling off and { enabled: true } overrides the auto-disable guard.

For live (WebSocket-fused) versions of the schedule and spotlight views, see @playlive/react-pipeline's useCurrentBlockRaised / useLiveSchedule.

The useFetch primitive

import { useFetch } from "@playlive/react-data/core";

function useDonorSpotlight(campaignId: string | null) {
  return useFetch(
    ["donor-spotlight", campaignId], // key: any change ⇒ abort in-flight + refetch
    ({ signal }) => fetchDonorSpotlight({ campaignId: campaignId as string, signal }),
    { enabled: !!campaignId, refetchInterval: 30_000, retry: 2 },
  );
}
Export Kind Signature
useFetch React hook useFetch<T>(key: ReadonlyArray<unknown>, fetcher: Fetcher<T>, options?: UseFetchOptions): UseFetchResult<T>
Fetcher<T> type (ctx: { signal: AbortSignal }) => Promise<T>
PACKAGE_NAME const "@playlive/react-data" — runtime version-pinning.
KNOWN_URLS const Frozen, empty. See Twitch Extension URL disclosure.

behavior:

The domain hooks above wrap useFetch but do not forward the AbortSignal into @playlive/fundraiser-data — cancellation there discards the result rather than aborting the HTTP request. Reach for useFetch directly when true request-level cancellation matters.

Common options (UseFetchOptions)

Option Type Default Description
enabled boolean true Skip fetching when false. Toggling to true fetches.
refetchInterval number | false Poll every N ms. false, 0, or negative disables.
retry number | boolean 0 Retry count on error. true retries indefinitely.
retryDelay number 1000 Base backoff (ms): retryDelay * 2 ** attempt ±25 % jitter.
staleTime number Pass-through — honoured only by @playlive/react-query.
initialData unknown Pass-through — honoured only by @playlive/react-query.
maxPages number Pass-through — infinite-query cap in the other tier.
cachingEnabled boolean Pass-through — cache partitioning in the other tier.

The four pass-through keys exist so a route loader can hand the same options object to either tier without conditional-spread hacks.

Result (UseFetchResult<T>)

{
  /** Latest resolved data, or `undefined` until the first success. */
  data: T | undefined;
  /** Most recent error thrown by the fetcher, or `null` on success. */
  error: Error | null;
  /** `true` from mount (or `enabled` flipping true) until the first settle. */
  isLoading: boolean;
  /** `true` while neither data nor error exists — mirrors TanStack v5's `isPending`. */
  isPending: boolean;
  /** `true` whenever a fetch is in flight: initial load, poll tick, or `refetch()`. */
  isFetching: boolean;
  /** Manually trigger a refetch. Cancels any in-flight request. */
  refetch: () => Promise<void>;
}

isPending stays true for a disabled query with no cached data; isFetching is true for a polling tick on already-resolved data. They're orthogonal — branch on isPending for "never loaded", on isFetching for a background-refresh spinner.

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

Upstream spec

No external API surface of its own. Every hook delegates to a fetcher in @playlive/fundraiser-data, so the endpoints, authentication, and URL knobs are entirely that package's — configure it once at boot with configure() and these hooks inherit whatever you pointed it at (the Tiltify proxy, the Twitch charity service, and the Play Live schedule / lifetime-raised / leaderboard services). See that package's README for the full list.

Twitch Extension URL disclosure

The KNOWN_URLS export enumerates every absolute URL or host this package can fetch. It is empty. This package doesn't hardcode any production hosts — every endpoint is reached transitively through @playlive/fundraiser-data. Add that package's URLs (i.e. every URL you pass to configure(), or the matching ENV_URLS row) to your Extension submission's URL disclosure list.

import { KNOWN_URLS } from "@playlive/react-data";
console.log(KNOWN_URLS); // []

Examples

A complete campaign overlay

Configure once at boot, then chain hooks off the resolved campaign ID. Nullish campaignId auto-disables the dependent hooks, so there's no manual enabled bookkeeping while the campaign is still loading.

import { configure } from "@playlive/fundraiser-data/config";
import { extractCampaignAmounts, getDonorLevel } from "@playlive/fundraiser-data/projections";
import {
  useCampaign,
  useFlattenedDonations,
  useLifetimeRaised,
  useMilestones,
} from "@playlive/react-data";

configure({
  tiltifyProxyUrl: import.meta.env.VITE_TILTIFY_PROXY_URL,
  lifetimeApiUrl: "https://main.playlive.lifetime-raised.api.experience.stjude.org",
});

export function CampaignOverlay({
  teamUserSlug,
  slug,
}: {
  teamUserSlug: string;
  slug: string;
}) {
  const campaign = useCampaign(
    { charityType: "tiltify", teamUserSlug, slug },
    { refetchInterval: 15_000, retry: 2 },
  );

  // `undefined` until the campaign resolves — the hooks below stay
  // disabled (and `isPending`) until then.
  const campaignId = campaign.data?.id;

  const milestones = useMilestones(
    { charityType: "tiltify", campaignId },
    { refetchInterval: 60_000 },
  );
  const donations = useFlattenedDonations(
    // One page of 25 keeps a long-running campaign from walking its
    // full donation history on every poll tick.
    { campaignId, count: 25, maxPages: 1 },
    { refetchInterval: 10_000 },
  );
  const lifetime = useLifetimeRaised({ username: teamUserSlug });

  if (campaign.isPending) return <p className="overlay-status">Loading campaign…</p>;
  if (campaign.error) return <p className="overlay-error">{campaign.error.message}</p>;
  if (!campaign.data) return <p className="overlay-status">Campaign not found.</p>;

  const { currentAmount, goalAmount } = extractCampaignAmounts(campaign.data);
  const nextMilestone = (milestones.data ?? [])
    .filter((m) => m.active)
    .sort((a, b) => Number.parseFloat(a.amount.value) - Number.parseFloat(b.amount.value))
    .find((m) => Number.parseFloat(m.amount.value) > currentAmount);

  return (
    <section className="overlay" data-fetching={campaign.isFetching}>
      <h1>{campaign.data.name}</h1>

      <progress value={currentAmount} max={goalAmount || 1} />
      <p>
        ${currentAmount.toLocaleString()} of ${goalAmount.toLocaleString()}
        {lifetime.data !== null && lifetime.data !== undefined ? (
          <em> · ${lifetime.data.toLocaleString()} lifetime</em>
        ) : null}
      </p>

      {nextMilestone ? <p>Next up: {nextMilestone.name} @ ${nextMilestone.amount.value}</p> : null}

      {/* Children fail independently — the overlay stays on screen. */}
      {donations.error ? (
        <p className="overlay-error">Donations unavailable: {donations.error.message}</p>
      ) : (
        <ul>
          {(donations.data ?? []).slice(0, 5).map((d) => (
            <li key={d.id} data-level={getDonorLevel(d.amount.value)}>
              {d.donor_name} — ${d.amount.value}
            </li>
          ))}
        </ul>
      )}

      <button type="button" onClick={() => void campaign.refetch()} disabled={campaign.isFetching}>
        {campaign.isFetching ? "Refreshing…" : "Refresh"}
      </button>
    </section>
  );
}

A campaign picker (Tiltify OAuth flow)

useTiltifyUserAndTeamCampaigns guards against the literal "null" string, which is what a stale localStorage.getItem("userId") hands back — so the hook stays disabled instead of firing a doomed request.

import { useTiltifyUserAndTeamCampaigns } from "@playlive/react-data";

export function CampaignPicker({
  userId,
  onPick,
}: {
  userId: string | null;
  onPick: (campaignId: string) => void;
}) {
  const { data, error, isPending, isFetching, refetch } = useTiltifyUserAndTeamCampaigns(
    { userId },
    { retry: 3, retryDelay: 500 },
  );

  if (!userId) return <p>Sign in with Tiltify to pick a campaign.</p>;
  if (isPending) return <p>Loading your campaigns…</p>;
  if (error)
    return (
      <p>
        Couldn’t load campaigns: {error.message}{" "}
        <button type="button" onClick={() => void refetch()}>
          Retry
        </button>
      </p>
    );

  const campaigns = data ?? [];
  if (campaigns.length === 0) return <p>No campaigns found for this account.</p>;

  return (
    <select
      defaultValue=""
      disabled={isFetching}
      onChange={(e) => onPick(e.currentTarget.value)}
    >
      <option value="" disabled>
        Choose a campaign…
      </option>
      {campaigns.map((c) => (
        <option key={c.id} value={c.id}>
          {c.name}
        </option>
      ))}
    </select>
  );
}

Testing hooks without a test library

These hooks need nothing more than React itself, so a ~30-line hand-rolled renderHook helper is enough to test them — no @testing-library/react, no jsdom. Point configure() at a stub server (or register a demo provider) and poll the result:

import { configure, resetConfig } from "@playlive/fundraiser-data";
import { useCampaign } from "@playlive/react-data";
import { actAsync, cleanup, renderHook } from "./render-hook.ts";

configure({ tiltifyProxyUrl: "https://v5api.tiltify.com/api/", causeId: "demo-cause-st-jude" });

const { result } = renderHook(() => useCampaign({ charityType: "tiltify", id: "demo-campaign-a" }));
expect(result.current.isLoading).toBe(true);

await actAsync(async () => {
  for (let i = 0; i < 50 && result.current.isLoading; i++) {
    await new Promise((r) => setTimeout(r, 20));
  }
});

expect(result.current.data).not.toBeNull();
cleanup();
resetConfig();

License

MIT © St. Jude Children's Research Hospital