npm install @playlive/react-data
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.
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.
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>
);
}
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 | 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.
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.
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.
useFetch primitiveimport { 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:
key array is serialized (JSON.stringify) into a single effect
dependency — change it and the in-flight request is aborted and
re-issued.enabled: false aborts any in-flight request and clears
isLoading / isFetching.refetchInterval resets
the timer cleanly.AbortError is treated as cancellation, never surfaced as error.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.
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.
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/
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.
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); // []
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>
);
}
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>
);
}
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();
MIT © St. Jude Children's Research Hospital