npm install @playlive/react-pipeline
React bindings for @playlive/realtime-pipeline.
Successor to playlive-overlay-data-layer/src/websocket/* with zero
dependency on react-use-websocket-lite — built directly on Zustand v5's
useStore + useSyncExternalStore.
Pick the surface that matches how much of the legacy
UnifiedDataPipelineProvider API you actually want. Each tier is
strictly larger than the last and lives at its own subpath so unused
code is tree-shaken out.
| Tier | Subpath | Surface | Use when |
|---|---|---|---|
| 1 | @playlive/react-pipeline |
RealtimePipelineProvider + usePipelineValue (universal selector) |
Greenfield app; you want one selector primitive and nothing else. |
| 2 | @playlive/react-pipeline |
Tier 1 plus the typed per-slice hooks (usePipelineDonations(id), …) |
You want stable, typed one-liners for the common reads. |
| 3 | @playlive/react-pipeline/provider |
Tier 2 plus UnifiedDataPipelineProvider, useUnifiedDataPipeline, etc. |
Porting an existing overlay-data-layer consumer with minimal churn. |
| — | @playlive/react-pipeline/fusion |
REST + WS composition hooks (needs the optional TanStack peers) | You want "REST baseline + live WS delta" without hand-rolling it. |
| — | @playlive/react-pipeline/legacy |
Tier 3 plus useUDPStore / useUDPStoreApi / createUDPStore aliases |
Soft-deprecated shim for the legacy symbol names (one-release window). |
bun add @playlive/react-pipeline
bun add react react-dom zustand @playlive/realtime-pipeline @playlive/tiltify-core
Peer dependencies (jose-style — the consumer brings their own):
| Peer | Range | Required? |
|---|---|---|
react |
^19.0.0 |
yes |
react-dom |
^19.0.0 |
yes |
zustand |
^5.0.0 |
yes |
@playlive/realtime-pipeline |
workspace:* |
yes — store, connection, protocol types |
@playlive/tiltify-core |
workspace:* |
yes (type-only — stripped at compile time) |
@tanstack/react-query |
^5.0.0 |
optional — only for /fusion |
@playlive/react-query |
workspace:* |
optional — only for /fusion |
@playlive/fundraiser-data |
workspace:* |
optional — only for /fusion |
The three optional peers are declared optional: true in
peerDependenciesMeta; you only need them if you import from
@playlive/react-pipeline/fusion.
This package has no runtime dependencies — nothing but your own
peers ends up in the bundle.
import {
RealtimePipelineProvider,
usePipelineCampaigns,
usePipelineConnectionState,
usePipelineDonations,
} from "@playlive/react-pipeline";
const CAMPAIGN_ID = "5d4b0d3c-9f31-4a5c-8fd1-0a2b3c4d5e6f";
function App() {
return (
<RealtimePipelineProvider
url="wss://main.playlive.ws.api.experience.stjude.org"
initialCampaignIDs={[CAMPAIGN_ID]}
initialOverlayName="donation-bar"
initialTeamUserSlug="@playliver"
initialOverlayPath="/overlays/donation-bar"
autoConnect
>
<DonationBar campaignID={CAMPAIGN_ID} />
</RealtimePipelineProvider>
);
}
function DonationBar({ campaignID }: { campaignID: string }) {
const { connected, connecting, hasReceivedInitialData } = usePipelineConnectionState();
const campaigns = usePipelineCampaigns();
const donations = usePipelineDonations(campaignID);
if (connecting) return <p>Connecting…</p>;
if (!connected) return <p>Disconnected — reconnecting…</p>;
if (!hasReceivedInitialData) return <p>Waiting for first tick…</p>;
const campaign = campaigns.find((c) => c.id === campaignID);
const raised = Number(campaign?.total_amount_raised?.value ?? 0);
const goal = Number(campaign?.goal?.value ?? 0);
return (
<section>
<progress value={raised} max={goal || 1} />
<p>
${raised.toFixed(2)} of ${goal.toFixed(2)}
</p>
<ul>
{donations.slice(0, 5).map((d) => (
<li key={d.id}>
{d.donor_name} — ${d.amount.value}
</li>
))}
</ul>
</section>
);
}
RealtimePipelineProvider builds one Zustand store per provider
instance (from the initial* props, which are read once at first
construction) and opens a single PipelineConnection. connection.destroy()
runs on unmount. Children subscribe with any Tier 1 / Tier 2 hook.
Every hook that reads the store throws when no provider is mounted
above it; the four useOn* observer hooks deliberately soft-fail
(they no-op) so you can drop them into components that sometimes render
outside a pipeline tree.
| Subpath | Description |
|---|---|
@playlive/react-pipeline |
Default barrel — Tier 1 + Tier 2 (provider, usePipelineValue, typed slice hooks, observers). |
@playlive/react-pipeline/provider |
Tier 3 — UnifiedDataPipelineProvider, useUnifiedDataPipeline, useAddPipeline*, useIdentify, … |
@playlive/react-pipeline/fusion |
REST + WS composition hooks (useCurrentBlockRaised, useLiveSchedule, the useHybrid* family, …). |
@playlive/react-pipeline/legacy |
Soft-deprecated alias bundle (useUDPStore, useUDPStoreApi, createUDPStore, UDPStore, …). |
Each subpath ships an ESM bundle, a bun source condition, and .d.ts
declarations. Frontend-eligible bundles are not minified so stack
traces stay readable in production.
Full generated API documentation: https://packages.playlive.experience.stjude.org/p/@playlive/react-pipeline/docs/
| Export | Signature |
|---|---|
RealtimePipelineProvider |
(props: RealtimePipelineProviderProps) => ReactNode |
RealtimePipelineProviderProps |
Type. InitialProps + children, url?, autoConnect? (default true), webSocketCtor?, log?, store?, connection?. |
usePipelineValue |
<T>(selector: (s: PipelineStore) => T) => T |
PACKAGE_NAME |
"@playlive/react-pipeline" — for runtime version-pinning. |
KNOWN_URLS |
readonly string[] — frozen, empty. See the disclosure section. |
RealtimePipelineProviderProps extends
InitialProps, so every initial* key
(initialCampaignIDs, initialTeamCampaignIDs, initialCauseIDs,
initialFundraisingEventIDs, initialOverlayName, initialTeamUserSlug,
initialOverlayPath, initialOverlayConfig, initialAdminApiKey,
initialDonationTrainsEnabled, …) is a valid prop. There is no
charityType prop — set the charity type through the store's
setCurrentType action or via useCommonContextHooks (Tier 3).
usePipelineValue is single-arg by design. Zustand v5 dropped the third
equalityFn parameter on useStore to keep the package free of the
use-sync-external-store shim peer. For shallow / custom equality, wrap
your selector with useShallow from zustand/shallow (zero extra dep):
import { useShallow } from "zustand/shallow";
import { usePipelineValue } from "@playlive/react-pipeline";
const { connected, connecting } = usePipelineValue(
useShallow((s) => ({ connected: s.connected, connecting: s.connecting })),
);
Stable wrappers over usePipelineValue with a typed selector +
memo-stable empty-array/object fallbacks (no infinite-render churn from
fresh [] allocations — Decision §3 in CHANGELOG.md).
| Hook | Returns |
|---|---|
usePipelineCampaigns() |
TiltifyCampaign[] |
usePipelineTeamCampaigns() |
TiltifyTeamCampaign[] |
usePipelineFundraisingEvents() |
TiltifyFundraisingEvent[] |
usePipelineCauses() |
TiltifyCause[] |
usePipelineDonations() |
Record<string, TiltifyDonationWithTestFlag[]> |
usePipelineDonations(id) |
TiltifyDonationWithTestFlag[] (memo-stable [] if unknown) |
usePipelineDonationTrains(id, withStatus?) |
DonationTrain[] — withStatus is "ACTIVE" | "ENDED" | "ENDING" | "PENDING" |
usePipelineSubathonTimer(id, withStatus?) |
SubathonTimer[] — withStatus is "ACTIVE" | "INACTIVE" |
usePipelineDonorSpotlight(id) |
ComputedSpotlight | null |
usePipelineDonorSpotlightSettings() |
Record<string, DonorSpotlightSettings> |
usePipelinePolls(id) |
TiltifyPoll[] |
usePipelinePollDeltas(pollID) |
PollOptionDelta[] — server-computed "+$X on option Y" deltas |
usePipelineRewards(id) |
TiltifyReward[] |
usePipelineTargets(id) |
TiltifyTarget[] |
usePipelineMilestones(id) |
TiltifyMilestone[] |
usePipelineLeaderboardEntries(id) |
TiltifyLeaderboardEntry[] |
usePipelineLeaderboardExclusions(id) |
LeaderboardExclusion[] |
usePipelineLeaderboardExclusionsMap() |
Record<string, LeaderboardExclusion[]> |
usePipelineSchedule(id) |
ScheduleTransitionBlock[] — full updateSchedule WS push |
usePipelineSchedules() |
Record<string, ScheduleTransitionBlock[]> |
usePipelineLastScheduleTransition(id) |
ScheduleTransitionPayload | null — latest block crossing |
usePipelineLastScheduleTransitions() |
Record<string, ScheduleTransitionPayload> |
usePipelineDonationSum(id, options?) |
PipelineDonationSumResult — live per-currency sum over [start, end) |
usePipelineAuctionHouses() |
AuctionHouse[] |
usePipelineTwitchChat() |
TwitchChatMessage[] |
usePipelineConnectionState() |
PipelineConnectionState (10 split selectors — no false re-renders) |
usePipelineConnectionActions() |
PipelineConnectionActions (stable connect/disconnect/…) |
usePipelineSubscriptions() |
Record<string, TiltifyWebhookSubscription> |
usePipelineTiltifySettings() |
TiltifyWebSocketSettings |
usePipelineLastRefreshResponse() |
RefreshResponseMessage | null |
Companion types exported alongside them: PipelineConnectionState
(connected, connecting, charityType, connectionType, wsURL,
hasReceivedInitialData, lastIdentified, lastIdentityMessage,
lastFirehoseMessage, lastMessage), PipelineConnectionActions
(connect, disconnect, identify, refresh, reset,
setAutoConnect, setAdminApiKey), PipelineDonationSumOptions,
PipelineDonationSumByCurrency, PipelineDonationSumResult.
Push-style subscriptions that diff the store outside React's render
path. Each stashes handler in a ref, subscribes exactly once, and
treats whatever is already on the store at mount as the baseline (so
they never fire for pre-existing data). All four soft-fail without
a provider.
| Hook | Handler payload |
|---|---|
useOnScheduleTransition(handler, options?) |
ScheduleTransitionPayload |
useOnScheduleUpdate(handler, options?) |
{ campaignID, schedule: ScheduleTransitionBlock[] } |
useOnLeaderboardEntriesChange(handler, options?) |
{ campaignID, entries: TiltifyLeaderboardEntry[] } |
useOnLeaderboardExclusionsChange(handler, options?) |
{ campaignID, exclusions: LeaderboardExclusion[] } |
options is { campaignIDs?: readonly string[] } — omit it (or pass an
empty list) to observe every campaign.
useOnLeaderboardEntriesChange(
({ campaignID }) => {
queryClient.invalidateQueries({
queryKey: ["leaderboard-with-exclusions", "tiltify", campaignID],
});
},
{ campaignIDs: [campaignID] },
);
/provider)| Export | Notes |
|---|---|
UnifiedDataPipelineProvider |
Superset of RealtimePipelineProvider; adds legacy reloadOnErrorReconnect (default true) + debug (wires console.debug logging). |
UnifiedDataPipelineProviderProps |
Type. Omit<RealtimePipelineProviderProps, "children"> & { children, reloadOnErrorReconnect?, debug? }. |
useUnifiedDataPipeline() |
One-call kitchen-sink hook → UnifiedDataPipelineState. Re-renders on every store change. |
UnifiedDataPipelineState |
Type. PipelineStore + allRewards / allMilestones / allTargets / allPolls / allFundraisingEventSupportingCampaigns / tiltifySubscriptions legacy aliases. |
useAddPipelineCampaignIDs(params) |
Layout-effect hook. { mode: "ws" | "http"; initialCampaign: { id }; isTeam: boolean; causeID? } — no-op in "http" mode. |
useAddPipelineCampaigns(params) |
Layout-effect hook. { campaign?: TiltifyCampaign | null; teamCampaign?: TiltifyTeamCampaign | null }. Idempotent by id. |
useAddPipelineFundraisingEventIDs(params) |
Layout-effect hook. { mode; initialCampaign: { id; fundraising_event_id: string | null } }. |
useCommonContextHooks(params) |
{ url: string; mode: "ws" | "http"; charityType: CharityType } — syncs type + URL and connects when mode === "ws". |
useIdentify() |
Returns the store-bound identify action. The 11-positional-arg legacy overload is kept as @deprecated; the args are ignored and a one-shot console.warn fires. |
RealtimePipelineProvider / RealtimePipelineProviderProps are also
re-exported from /provider so a Tier-3 consumer needs exactly one
import specifier.
/fusion)These fuse the WebSocket firehose with REST reads from
@playlive/react-query /
@playlive/fundraiser-data. Importing this
subpath means you must also install the three optional peers.
| Hook | Returns |
|---|---|
useCurrentBlockRaised(campaignID, start, end, opts?) |
{ raised, data, isLoading, isError, refetch } — REST baseline + WS delta over [start, end). opts: { enabled?, queryOptions?, demoMode? }. |
useLiveSchedule(params, opts?) |
{ schedule, isLoading, isError, refetch }. params: { charityType, campaignID, isTeam? }; opts: { enabled?, queryOptions?, demoMode?, demoIntervalMs? }. |
useCurrentScheduleItem(schedule, opts?) |
{ currentItem, nextItem } — pure client-side rollover projection with a self-scheduling boundary timer. |
useLinkedTeamCampaign(params, opts?) |
{ shouldFetchLinkedTeamCampaign, resolvedLinkedTeamCampaign, linkedTeamTotalAmount, linkedTeamGoalAmount, isLoadingLinkedTeamCampaign, debug }. |
useDonorSpotlight(params, opts?) |
{ spotlight, isLoading, refetch }. params: { campaignID, isTeam, live, mode }; opts: { pollingIntervalMs?, enabled?, queryOptions? }. |
useLiveSchedule resolves in a strict fallback order: full
updateSchedule WS push → lastScheduleTransitions synthesis (demo
only) → REST useSchedule → demo fixture.
/fusion)Overlay-parity hooks — the ones every Play Live overlay used to keep a
local copy of — shipped here so they can be imported instead. Each takes a
mode: "ws" | "http" that picks between:
"ws" — read the Tier-2 store selector, latch a local hasLoadedX
boolean on first arrival (or on the server's "feature disabled"
signal), and call updateTiltifySettings({ <sliceKey> }) whenever the
local xEnabled toggle flips so the pipeline dynamically un/subscribes."http" — delegate to the matching @playlive/react-query REST hook
(useRewards, useTargets, …) and ignore the WS slice.All of them share useLoadingTimeout — a 15 s safety net that
force-completes the WS branch when the store never delivers.
| Hook | Params → Returns |
|---|---|
useHybridRewards({ campaignID, isTeam, mode, initialRewardsEnabled? }) |
{ currentRewards, hasLoadedRewards, setHasLoadedRewards, isLoadingRewards, isLoading, rewardsEnabled, setRewardsEnabled } |
useHybridTargets({ campaignID, isTeam, mode }) |
{ currentTargets, hasLoadedTargets, setHasLoadedTargets, isLoadingTargets, isLoading, targetsEnabled, setTargetsEnabled } |
useHybridMilestones({ campaignID, isTeam, mode }) |
{ currentMilestones, isLoadingMilestones, milestonesEnabled, setMilestonesEnabled } |
useHybridPolls({ campaignID, isTeam, mode }) |
{ currentPolls, isLoadingPolls, pollsEnabled, setPollsEnabled } |
useHybridCause({ mode, initialCampaign }) |
{ cause, isLoading } — single-slot semantics. |
useHybridFundraisingEvent({ mode, initialCampaign }) |
{ fundraisingEvent, isLoading } — single-slot semantics. |
useHybridFundraisingEventSupportingCampaigns({ mode, initialFundraisingEvent, initialSupportingCampaignsEnabled? }) |
{ supportingCampaigns, isLoading, isLoadingSupportingCampaigns, hasLoadedSupportingCampaigns, supportingCampaignsEnabled, setSupportingCampaignsEnabled } |
useHybridDonationTrains({ campaignID, mode, initialDonationTrainsEnabled?, withStatus?, http?, auth? }) |
{ currentDonationTrains, hasLoadedDonationTrains, setHasLoadedDonationTrains, isLoadingDonationTrains, isLoading, donationTrainsEnabled, setDonationTrainsEnabled } |
useHybridAuctionHouses({ mode, enabled, campaignID }) |
{ auctionHouses, auctionHouseTotal, auctionHousesEnabled, setAuctionHousesEnabled } — WS-only (no REST fallback yet). |
useHybridLeaderboardWithExclusions({ charityType, campaignID, isTeam?, timeType?, count?, startDate?, endDate?, enabled?, continuouslyUpdate? }) |
{ entries, isLoading } — WS-driven invalidation over the REST leaderboard, with a LEADERBOARD_SAFETY_POLL_MS (60 s) backstop. |
useLoadingTimeout({ mode, isLoading, onFinishedLoading, timeoutAmount? }) |
void — the shared safety-net timer primitive. |
LEADERBOARD_SAFETY_POLL_MS is exported alongside the hooks. Return
shapes are preserved verbatim from the original overlay hooks, so
existing call sites can collapse to a re-export.
/legacy)Soft-deprecated. Behavior is identical to the canonical exports — rename the import specifier once and the rest of your code base keeps working:
| Legacy name | Canonical replacement |
|---|---|
useUDPStore |
usePipelineValue (Tier 1) / typed slice hooks (Tier 2) |
useUDPStoreApi |
Use a typed slice hook, or reach for the raw store API |
createUDPStore |
createPipelineStore from @playlive/realtime-pipeline/store |
UDPStore (type) |
PipelineStore from @playlive/realtime-pipeline/store |
UDPStoreApi (type) |
PipelineStoreApi from @playlive/realtime-pipeline/store |
CharityTypes |
Re-exported from @playlive/realtime-pipeline/protocol |
useUDPStore / useUDPStoreApi throw with a
"must be called inside <UnifiedDataPipelineProvider>" message when no
provider is mounted. The subpath also re-exports every Tier 1 + 2 + 3
symbol under its canonical name, plus the PipelineStore /
PipelineStoreApi types.
playlive-overlay-data-layerOne-shot, sed-friendly rewrite of every import specifier (no body edits needed for the happy path):
# Tier 3 — kitchen-sink (drop-in replacement)
rg -l '@playlive/overlay-data-layer/websocket' . \
| xargs sed -i '' \
-e 's|@playlive/overlay-data-layer/websocket|@playlive/react-pipeline/provider|g'
For the soft-deprecated names (useUDPStore, createUDPStore, …)
substitute /legacy instead of /provider to opt into the alias
bundle, then migrate at your leisure — the aliases are kept for a
one-release window.
Two call-site changes are not covered by the sed:
useIdentify(sendJsonMessage, campaignIDs, …) — the positional args
are now ignored (the connection reads identify inputs off the store).
Drop them; the deprecated overload still compiles and warns once.useWebSocket from react-use-websocket-lite — replaced by
createPipelineConnection in @playlive/realtime-pipeline/connection,
which RealtimePipelineProvider mounts for you.The Legacy aliases table above is the full per-symbol mapping.
No external API surface — this package fetches nothing itself. It binds
React to @playlive/realtime-pipeline, which
owns the WebSocket connection and frames every byte on the wire; the
message catalogue and its types are exported from
@playlive/realtime-pipeline/protocol. The /fusion hooks additionally
read REST baselines through
@playlive/react-query and
@playlive/fundraiser-data — see those packages
for the endpoints and URL knobs involved.
KNOWN_URLS enumerates every absolute URL or host this package can
reach. It is empty. This package only opens the WebSocket URL the
consumer hands to <RealtimePipelineProvider url={…}> (or the store's
DEFAULT_WEBSOCKET_URL, wss://main.playlive.ws.api.experience.stjude.org).
Disclose that URL — plus the KNOWN_URLS of @playlive/realtime-pipeline
and, if you import /fusion, @playlive/fundraiser-data — on your
Extension submission.
import { KNOWN_URLS } from "@playlive/react-pipeline";
console.log(KNOWN_URLS); // []
The canonical fusion use case: fetch a REST baseline once, then add the
WebSocket donation delta on top so the number ticks up on every donation
instead of every 30-second poll. Requires the optional peers plus a
<QueryClientProvider> above the pipeline provider.
import { configure } from "@playlive/fundraiser-data/config";
import { getConfigForEnv } from "@playlive/fundraiser-data/environments";
import { RealtimePipelineProvider } from "@playlive/react-pipeline";
import {
useCurrentBlockRaised,
useCurrentScheduleItem,
useLiveSchedule,
} from "@playlive/react-pipeline/fusion";
import { makeQueryClient } from "@playlive/react-query";
import { QueryClientProvider } from "@tanstack/react-query";
configure(getConfigForEnv("prod", { tiltifyProxyUrl: import.meta.env.VITE_TILTIFY_PROXY_URL }));
const queryClient = makeQueryClient();
const CAMPAIGN_ID = "5d4b0d3c-9f31-4a5c-8fd1-0a2b3c4d5e6f";
export function App() {
return (
<QueryClientProvider client={queryClient}>
<RealtimePipelineProvider
url="wss://main.playlive.ws.api.experience.stjude.org"
initialCampaignIDs={[CAMPAIGN_ID]}
initialOverlayName="schedule-block"
initialTeamUserSlug="@playliver"
>
<BlockTotal campaignID={CAMPAIGN_ID} />
</RealtimePipelineProvider>
</QueryClientProvider>
);
}
function BlockTotal({ campaignID }: { campaignID: string }) {
const { schedule, isLoading: scheduleLoading, isError } = useLiveSchedule({
charityType: "tiltify",
campaignID,
});
const { currentItem, nextItem } = useCurrentScheduleItem(schedule);
const { raised, isLoading } = useCurrentBlockRaised(
campaignID,
currentItem?.starts_at,
currentItem?.ends_at,
);
if (scheduleLoading) return <p>Loading schedule…</p>;
if (isError) return <p>Schedule unavailable.</p>;
if (!currentItem) return <p>Up next: {nextItem?.name ?? "nothing scheduled"}</p>;
return (
<section>
<h2>{currentItem.name}</h2>
<p>{isLoading ? "…" : `${raised.toFixed(2)} raised this block`}</p>
</section>
);
}
If you already have the baseline (from a route loader, say), skip the fusion hook and reduce the firehose yourself:
import { usePipelineDonationSum } from "@playlive/react-pipeline";
function BlockDelta({
campaignID,
baseline,
}: {
campaignID: string;
// `GET /schedules/campaigns/{id}/raised`
baseline: { raised: number; currency: string; asOf: string };
}) {
const delta = usePipelineDonationSum(campaignID, {
start: baseline.asOf,
end: "2025-09-20T23:00:00.000Z",
currency: baseline.currency,
});
const total = baseline.raised + (delta.raised ?? 0);
return (
<p>
${total.toFixed(2)} ({delta.donationCount} live donations since baseline)
</p>
);
}
usePipelineDonationSum matches start <= completed_at < end, dedupes
by donation id (last write wins, so post-edit corrections propagate),
and excludes test: true donations unless you pass
includeTest: true — which keeps it consistent with the REST baseline.
Both examples above are complete and runnable: point
<RealtimePipelineProvider url={…}> at your pipeline WebSocket and the
rest is copy-paste.
MIT © St. Jude Children's Research Hospital