@peerfold/react
v1.2.0
Published
React provider + hooks for the Peerfold API, built on @peerfold/api-client and @peerfold/react-blocks. Plain React, no data-fetching runtime dependency; SSR-safe.
Readme
@peerfold/react
React provider + hooks over @peerfold/api-client. Built with
plain React — no data-fetching runtime dependency — and SSR-safe (no
window at module scope; fetching runs in effects, so a server render just
returns the initial loading state).
Deviation from the PRD (§5.3): the PRD sketched TanStack Query "under the hood". We keep the core zero-dependency instead — the hooks are a thin, auditable
useEffect+AbortControllerlayer. Drop TanStack Query in at the app level if you want its cache; these hooks don't require it.
Install
npm install @peerfold/react @peerfold/api-clientreact (18 or 19) is a peer dependency; @peerfold/api-client is what you build
the client with.
A note on naming (1.0.0). The component and class names are
PeerfoldProvider/PeerfoldClient/PeerfoldError, the hook isusePeerfoldClient, the wire headers arePeerfold-Version/X-Peerfold-*and the browser storage keys arepeerfold.*. 0.1.x shipped the oldHubLMS-prefixed names; 0.2.0 renamed them and 1.0.0 is the release where the API stopped answering the old wire spellings as well. Pin^1.0.0.
Setup
Build a client (with a learner token from your auth flow) and pass it to the provider. The provider constructs nothing itself, so it is safe to render on the server.
import { PeerfoldClient } from "@peerfold/api-client";
import { PeerfoldProvider } from "@peerfold/react";
const client = new PeerfoldClient({
baseUrl: "https://acme.site.hublms.com",
learnerToken: () => tokenStore.get(), // provider fn → always reads the current token
});
export function App() {
return (
<PeerfoldProvider client={client}>
<Catalog />
</PeerfoldProvider>
);
}Read hooks
Each returns { data, error, loading, reload }.
import { useMe, useCatalog, useCourse, useEnrollments, useCertificates } from "@peerfold/react";
function Catalog() {
const { data: courses, loading, error, reload } = useCatalog();
if (loading) return <Spinner />;
if (error) return <Error msg={error.message} onRetry={reload} />;
return <ul>{courses!.map((c) => <li key={c.id}>{c.title}</li>)}</ul>;
}
function CoursePage({ slug }: { slug: string }) {
const { data: course } = useCourse(slug); // fetch is skipped while slug is falsy
const { data: me } = useMe();
// …
}useCatalog, useEnrollments, useCertificates eagerly collect all pages via
the client's iterate(); use client.*.iterate() directly for manual paging.
Optimistic progress
useProgressMutation is an optimistic, coalescing, retrying queue for progress
events — call record freely from the player:
import { useProgressMutation } from "@peerfold/react";
function Lesson({ enrollmentId, lessonId }: { enrollmentId: string; lessonId: string }) {
const { record, pending, error } = useProgressMutation({
onSuccess: (result) => setProgress(result.percent_complete ?? 0),
});
return (
<button
onClick={() =>
record(enrollmentId, { event_id: crypto.randomUUID(), type: "lesson_completed", lesson_id: lessonId })
}
>
Mark complete {pending > 0 ? `(syncing ${pending}…)` : ""}
</button>
);
}What it does:
- Coalesces redundant events — repeated records for the same (enrollment, lesson, type) collapse to the latest one still queued.
- Stable idempotency key per item — retries replay the server's stored response instead of double-appending.
- Retries 429/5xx and transient network errors with exponential backoff,
then surfaces the failure via
onError. - Optimistic —
recordreturns immediately; your UI advances without awaiting the network.
Signing in a HubSpot membership contact (the assertion rail)
A HubSpot CMS page with a signed-in membership contact can prove who that
contact is without a second sign-in. The page's server render emits a keyed-hash
assertion — payload and sig, never the secret — and useMembershipAssertion
posts the pair to POST /api/v1/auth/membership-assertion, which answers a
learner token. It is the same exchange @peerfold/surfaces' peerfold.js makes
for the HubL modules, so a React island and a HubL module on one page
authenticate through one door.
import { PeerfoldProvider, useMembershipAssertion } from "@peerfold/react";
function MemberIsland({ baseUrl, publishableKey, assertion }) {
const { client, signedIn, loading } = useMembershipAssertion({
baseUrl,
publishableKey,
assertion, // { payload, sig } from the page's server render, or null
});
if (loading) return null;
if (!signedIn || !client) return <SignedOut />;
return (
<PeerfoldProvider client={client}>
<MyLearning />
</PeerfoldProvider>
);
}Three things it will not do. It never takes a signing secret (there is no
parameter for one, and a secret in a browser bundle is a secret on the public
internet). It posts once per assertion — Cloud burns it on use and holds it
to a ±300s window, so two islands must not share one. And it is silent when
there is no assertion, because a visitor who is not signed into membership is
the ordinary case, not an error: signedIn is false and error is null.
exchangeMembershipAssertion is the same POST without React attached.
The two hooks the lesson plane needs
useLearnerProgress(config, courseRef)—GET /api/v1/lms/progress: completed lessons, percent complete, the resume target, and the last score per quiz. A 404 means "no enrollment", so it resolves todata: nullwith no error. It takes a transport config rather than the provider's client because@peerfold/api-clientdoes not expose this read yet; when it does, the config parameter goes away and the return shape stays.useQuizAttempt(enrollmentId)— returns asubmitfunction shaped for@peerfold/react-blocks'createQuizBlock({ submit }), so a rendered quiz posts to/quiz-attemptsand prints what the server graded. It decides nothing: no score, no pass, no completion.
The rest of the lesson: forms, resources, discussions, hosted video
Five block types used to come out of @peerfold/react-blocks as a card pointing
back at the hosted player, because each needs a request that package will not
make. These are the requests.
useLessonForms(lessonId)+useLessonFormSubmit(lessonId)— a form block travels carrying a slug, so its questions, its labels and its destination are resolved at read time from a row the workspace owns. The read answers with the reader's own stored answers already in it. Hand both tocreateFormBlock({ forms, submit }). Onlyprofile_updateandhubspot_formcome back: the three public targets go throughGET /api/forms/{slug}on an approved site, so a lead-capture block is absent from the list and keeps its card.useResources()— the workspace's resource library, every page collected. One read serves everyresourcesblock on a page; hand it tocreateResourcesBlock({ resources }).useLessonDiscussion(lessonId)— the conversation, areplyfunction, areload, and a room switcher. Feed all four to<DiscussionPanel>. The thread is a real cohort thread and is null until somebody speaks, so the composer is offered on an empty room and the first reply mints the thread. Re-read after a reply rather than splicing it in: the write may have minted the thread, which movesthread,has_postedand the composer's gate together.useDiscussionEmbeds(blocks)— adiscussionBLOCK is a pointer: a room slug or a thread slug, frozen into the published revision the way a link is. This hands the lesson body over, anddiscussionEmbedTargets(in@peerfold/react-blocks, where the props are already understood) walks the tree, deduplicates by slug and merges the widestlimit, so a lesson that embeds one thread twice makes one request. It reads the community rail rather than the LMS one, because that is where a room and a thread live, and returns{ threads, cohorts, reply }shaped forcreateDiscussionEmbedBlock. A pointer that resolves to nothing becomes that entry'sunavailablesentence rather than failing the page: soft-deleting a thread frees its slug and a room this member may not enter is invisible rather than forbidden, and one dead pointer should cost one block. Itsreplyposts a comment to a thread that already exists, which is what separates it fromuseLessonDiscussion— a discussion lesson usually has no thread at all and its reply mints one.<HostedVideo>— the signed HLS manifest offCourseDetail.videos. Safari and every browser on iOS play it from a plainsrc. Everywhere else needs a player, and that stays yours:import { HostedVideo, hostedVideoAsset } from "@peerfold/react"; import { PlayerCard, VideoBlock } from "@peerfold/react-blocks"; const video = (props) => { const asset = hostedVideoAsset(props.block, course.videos); return asset ? ( <HostedVideo {...asset} loadHls={() => import("hls.js")} fallback={<PlayerCard options={props.options} />} /> ) : ( <VideoBlock {...props} /> ); };hls.jsis an optional peer: install it if you want that path, and nothing breaks if you do not. The import lives in your code rather than inside this package on purpose, because a bareimport("hls.js")here would be a static edge in every bundle built against@peerfold/react, including the many that never show a hosted video. A page that loaded hls.js from a script tag needs no prop at all: the default loader findswindow.Hls, which is the same door@peerfold/surfacesopens. With neither,fallbackis drawn.hostedVideoAssetreturns null for YouTube, Vimeo, Wistia, Synthesia, Canva and direct file URLs, so<VideoBlock>keeps drawing all of those.
Build each factory inside a useMemo. They return a new component type per call,
and a new type remounts.
Enrolling, and the exam player
useEnroll() is the mutation behind a Start button. It is idempotent server-side
— enrolling twice returns the existing row — and it resolves to null rather
than throwing when the server refuses, which is the shape a click handler wants:
const { enroll, pending, error } = useEnroll();
<button disabled={pending} onClick={async () => {
const enrollment = await enroll({ courseSlug: course.slug });
if (enrollment) go(enrollment.continue_url);
}}>Start course</button>The access gate is the server's, always. A course behind a price, a membership,
an organization rule or a visibility rule is refused here whatever the catalog
card looked like — so do not pre-check access_state and skip the call; check
error afterwards and send the buyer to checkout.
useExamAttempt() is useQuizAttempt()'s twin, minus the blockId an exam does
not have (an exam is a whole lesson, not a block inside one):
const submitExam = useExamAttempt(enrollment?.id);
const result = await submitExam({ lessonId, answers });Branch on all three outcomes. pending_manual means somebody has to read the
answers and no score is coming from this call — say so rather than showing a
zero. A refusal arrives as { ok: false, error } with a 200, not a thrown error.
Commerce — the hooks that work with nobody signed in
These need a publishable key on the client, not a learner token. Configure
publishableKey and they work for a visitor who has never signed in, which is
what makes guest checkout and its claim-link path reachable.
const [token, setToken] = useState(() => localStorage.getItem("peerfold.cart_token"));
const cart = useCart({
token,
onToken: (t) => {
setToken(t);
if (t) localStorage.setItem("peerfold.cart_token", t);
else localStorage.removeItem("peerfold.cart_token");
},
});
await cart.add(courseId); // creates the cart if there is not one yet
await cart.remove(courseId);
const { checkout_url, order_id } = (await cart.checkout({ return_url })) ?? {};You own the token's storage, deliberately. useCart touches no
localStorage, no cookie and no window, so it renders on a server without a
guard and works unchanged in React Native or on a page whose storage policy is
somebody else's decision. A 404 on any cart call means the token is unknown,
expired, converted or from another workspace — indistinguishably, on purpose —
and the hook heals it by reporting null through onToken, so a stale token in
somebody's browser fixes itself on their next click.
// The return page. Poll until it settles — `?status=success` in your own return
// URL is a string you put there, not proof the payment landed.
const { status, claimEmailMasked, settled } = useOrderStatus(orderId);
const { data: tiers } = useMembershipTiers();
const { start: join } = useJoinTier(); // join(tierId)
const { data: programs } = usePrograms();
const { data: program } = useProgram(slug);
const { start: buyProgram } = useProgramCheckout(); // buyProgram(idOrSlug)useOrderStatus stops on paid or cancelled, or after its timeout (60s by
default) — settled tells you which end you reached. pending never means
"wrong id": the endpoint answers it for an order that does not exist, belongs to
another workspace, or was simply not paid yet, indistinguishably.
usePrograms / useProgram send whichever credentials are configured and let
the server pick its mode: with a learner token as well as the key, the payload
carries that learner's own progress and a completed flag per course.
