npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 + AbortController layer. 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-client

react (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 is usePeerfoldClient, the wire headers are Peerfold-Version / X-Peerfold-* and the browser storage keys are peerfold.*. 0.1.x shipped the old HubLMS-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 — record returns 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 to data: null with no error. It takes a transport config rather than the provider's client because @peerfold/api-client does not expose this read yet; when it does, the config parameter goes away and the return shape stays.
  • useQuizAttempt(enrollmentId) — returns a submit function shaped for @peerfold/react-blocks' createQuizBlock({ submit }), so a rendered quiz posts to /quiz-attempts and 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 to createFormBlock({ forms, submit }). Only profile_update and hubspot_form come back: the three public targets go through GET /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 every resources block on a page; hand it to createResourcesBlock({ resources }).

  • useLessonDiscussion(lessonId) — the conversation, a reply function, a reload, 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 moves thread, has_posted and the composer's gate together.

  • useDiscussionEmbeds(blocks) — a discussion BLOCK 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, and discussionEmbedTargets (in @peerfold/react-blocks, where the props are already understood) walks the tree, deduplicates by slug and merges the widest limit, 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 for createDiscussionEmbedBlock. A pointer that resolves to nothing becomes that entry's unavailable sentence 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. Its reply posts a comment to a thread that already exists, which is what separates it from useLessonDiscussion — a discussion lesson usually has no thread at all and its reply mints one.

  • <HostedVideo> — the signed HLS manifest off CourseDetail.videos. Safari and every browser on iOS play it from a plain src. 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.js is 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 bare import("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 finds window.Hls, which is the same door @peerfold/surfaces opens. With neither, fallback is drawn. hostedVideoAsset returns 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.