โ† All docs  ยท  Home

Using the Freedback widgets in React

The Freedback widgets are vanilla custom elements (<freedback-stars>, <freedback-thumb>, <freedback-scalar>, <freedback-comment>, <freedback-issue>, <freedback-tag>) shipped as a single dependency-free script, freedback-widgets.js. Because they are real DOM elements configured entirely through data-* attributes, React renders them natively โ€” React always forwards data-* (and aria-*) attributes to the DOM, so for the common case you can drop the tag straight into JSX with no wrapper or refs. (One subtlety โ€” a widget reads its data-* in connectedCallback, so the attributes must be set before the element is attached; plain JSX does this, but a ref that configures the element after mount would be too late. A tiny wrapper makes this bulletproof โ€” see A reusable wrapper.)

Status: as of 1.0 the widgets ship as the npm package @freedback/widgets with an ESM build (side-effect import registers the elements), bundled TypeScript types (zero-config JSX), and outcome events (freedback:published / freedback:deleted / freedback:error). The dependency-free <script src> path keeps working unchanged. The "dead-simple" npm add @freedback/widgets โ†’ import "@freedback/widgets" โ†’ <freedback-stars/> flow now works. A separate @freedback/react wrapper (camelCase props + typed callbacks) is the only remaining stretch goal โ€” see API review.


TL;DR (the dead-simple path)

  1. Install and register the elements once (side-effect import):

    npm add @freedback/widgets
    
    // main.tsx (or any module that runs once at startup)
    import "@freedback/widgets";
    
  2. Drop an element into any .tsx โ€” config is all data-*, so React just renders it, and the bundled types make it type-check with no setup:

    export function ProductRating() {
      return (
        <freedback-stars
          data-target="https://shop.example/product/42"
          data-read="https://collect.example/index"
          data-publish="https://feedback.example/annotations/"
          data-sign=""
        />
      );
    }
    

That's it. The widget renders the current aggregate from data-read, and (because data-sign is present) lets the visitor publish a self-signed rating to data-publish. To react to the outcome in your app, listen for the outcome events.

No build step? You can still load the canonical script directly โ€” it registers the same six elements as a global side effect:

<script src="https://freedback.net/widgets/freedback-widgets.js"></script>
<!-- or a CDN: <script src="https://unpkg.com/@freedback/widgets"></script> -->

Step by step (Vite + React + TypeScript)

1. Install + register the elements

npm add @freedback/widgets

Register the six custom elements once for the whole app with a side-effect import (registration is global and idempotent โ€” do it once, not per component):

// main.tsx โ€” run once at app startup
import "@freedback/widgets";

The package ships both an ESM build (what import resolves to in a bundler like Vite/webpack) and a UMD/IIFE build (what a <script src> loads). If you prefer no bundler, load the canonical script instead (CDN or vendored copy in public/):

<script src="https://freedback.net/widgets/freedback-widgets.js"></script>
<!-- or pin via the package: https://unpkg.com/@freedback/widgets -->

You can also import the helper + identity API from the same package:

import { jcs, starBody, exportIdentity, rotateIdentity } from "@freedback/widgets";

2. TypeScript: nothing to do โ€” types are bundled

@freedback/widgets ships its own .d.ts. It augments both React.JSX.IntrinsicElements (React 19) and the global JSX namespace (React โ‰ค 18), and the framework-neutral HTMLElementTagNameMap, so all six tags type-check with zero consumer setup โ€” no hand-written shim. (Older releases told you to add an src/freedback.d.ts; that is no longer needed โ€” delete it if you have one.) The tags also type the onPublished / onDeleted / onError props for the outcome events, and document.querySelector("freedback-stars") is typed as the element.

3. Insert the widget in your .tsx

function Feedback({ url }: { url: string }) {
  const collect = "https://collect.example/index";
  const publish = "https://feedback.example/annotations/";
  return (
    <section>
      <h3>Rate this</h3>
      <freedback-stars data-target={url} data-read={collect} data-publish={publish} data-sign="" />

      <h3>๐Ÿ‘ / ๐Ÿ‘Ž</h3>
      <freedback-thumb data-target={url} data-read={collect} data-publish={publish} data-sign="" />

      <h3>Difficulty (0โ€“10)</h3>
      <freedback-scalar
        data-target={url} data-read={collect} data-publish={publish} data-sign=""
        data-worst="0" data-best="10" data-step="1"
      />

      <h3>Comments</h3>
      <freedback-comment data-target={url} data-read={collect} data-publish={publish} data-sign="" />

      <h3>Report a problem</h3>
      <freedback-issue data-target={url} data-read={collect} data-publish={publish} data-sign="" />

      <h3>Tags</h3>
      <freedback-tag data-target={url} data-read={collect} data-publish={publish} data-sign="" />
    </section>
  );
}

4. The attributes

Attribute Required Meaning
data-target yes the URI the feedback is about (your page/product/item)
data-read for display endpoint that returns aggregates โ€” a collection server's /index or a feedback server's /annotations/. Omit for a write-only widget.
data-publish to submit a feedback server's /annotations/. Omit for a read-only widget.
data-sign โ€” presence enables self-signed publishing (a per-browser P-256 key in IndexedDB, WebCrypto). Write it as data-sign="" in JSX.
data-token โ€” an OAuth bearer for the app-managed identity instead of data-sign. data-sign wins if both are set.
data-license โ€” optional license IRI (e.g. https://creativecommons.org/licenses/by/4.0/) set as the published annotation's W3C rights property, on both the signed and bearer paths (data licensing, ADR 0022). Omit to fall under the server's default license (/.well-known/freedback).
data-worst / data-best / data-step scalar only the <freedback-scalar> scale.

<freedback-issue> (ADR 0023) is the problem-report widget: a textarea plus a Report button, listing the issues reported for the target (each marked with a โš  via the .fb-issue styles). On the wire it publishes a plain W3C oa:TextualBody under the standard oa:editing motivation โ€” zero new vocabulary. It takes the same attributes as <freedback-comment>.

A reusable wrapper (optional)

Plain JSX is fine, but if you use the widgets in many places โ€” or want to be immune to the connectedCallback-before-attributes timing note above โ€” wrap them in one small component that sets the attributes while the element is detached, then appends it:

// FreedbackWidget.tsx
import { useEffect, useRef } from "react";

type Kind = "stars" | "thumb" | "scalar" | "comment" | "issue" | "tag";

export function FreedbackWidget({ kind, ...data }: { kind: Kind } & Record<`data-${string}`, string>) {
  const host = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const el = document.createElement(`freedback-${kind}`);
    for (const [k, v] of Object.entries(data)) el.setAttribute(k, v); // set BEFORE connect
    host.current!.replaceChildren(el);                                 // now connectedCallback sees full config
    return () => host.current?.replaceChildren();
  });
  return <div ref={host} />;
}
<FreedbackWidget kind="stars" data-target={url} data-read={collect} data-publish={publish} data-sign="" />

The project's live showcase uses exactly this pattern โ€” see demo-react/src/FreedbackWidget.jsx, which renders the shipped widgets against an in-browser mock backend (the demo at https://freedback.net).

5. Notes & gotchas


Outcome events (freedback:published / freedback:deleted / freedback:error)

After a publish or delete, the widget dispatches a CustomEvent on its host element โ€” additive to the existing DOM behavior (the aggregate refresh / .fb-status text), so nothing you relied on changes:

Event When event.detail
freedback:published the POST succeeded { response, annotation } โ€” the parsed server response and the annotation that was sent
freedback:deleted a delete succeeded (right to erasure, ADR 0021) { annotation, response } โ€” the erased annotation's dedup id and the raw DELETE response (204)
freedback:error the POST or DELETE failed { error } โ€” the Error thrown

The events bubble and are composed, so you can also listen on a container. The bundled types add typed onPublished / onDeleted / onError props and a typed addEventListener.

In React, attach the listeners via a ref in useEffect (custom events aren't React's synthetic on* props, so a ref is the reliable way):

import { useEffect, useRef } from "react";

function ProductRating({ url }: { url: string }) {
  const ref = useRef<HTMLElement>(null);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const onPublished = (e: Event) => {
      const { response, annotation } = (e as CustomEvent).detail;
      console.log("thanks! stored:", response, "sent:", annotation);
    };
    const onError = (e: Event) => {
      console.warn("publish failed:", (e as CustomEvent).detail.error);
    };
    el.addEventListener("freedback:published", onPublished);
    el.addEventListener("freedback:error", onError);
    return () => {
      el.removeEventListener("freedback:published", onPublished);
      el.removeEventListener("freedback:error", onError);
    };
  }, []);

  return (
    <freedback-stars
      ref={ref}
      data-target={url}
      data-read="https://collect.example/index"
      data-publish="https://feedback.example/annotations/"
      data-sign=""
    />
  );
}

The ref here only observes events โ€” it does not configure the element, so the connectedCallback-before-attributes timing note does not apply (the data-* are set by JSX before mount). If you also need to set attributes imperatively, use the reusable wrapper, which can forward these listeners too.


API review โ€” gaps to "dead simple"

Done in 1.0

The packaging/ergonomics gaps this tutorial originally surfaced are resolved in the @freedback/widgets 1.0 package:

  1. Published npm package. @freedback/widgets (the scope reserved in docs/naming.md) ships with main/module/exports/types, so npm add @freedback/widgets works. โœ…
  2. ESM build + side-effect registration. import "@freedback/widgets" registers the elements; named imports expose the helper + identity API. The IIFE/UMD build still powers the <script src> path unchanged. โœ…
  3. Bundled TypeScript types. A shipped .d.ts augments React.JSX.IntrinsicElements (React 19), the global JSX namespace (React โ‰ค 18), and HTMLElementTagNameMap โ€” zero consumer setup. โœ…
  4. Outcome events. freedback:published / freedback:error are dispatched on each widget (see Outcome events). โœ…

Still future work

  1. Optional thin React wrapper. A separate @freedback/react package exposing <FreedbackStars target=โ€ฆ read=โ€ฆ publish=โ€ฆ sign onPublished=โ€ฆ /> with real camelCase props + typed callbacks would be the most idiomatic React surface and would hide the custom-element/data-* details entirely. The custom-element + events surface in 1.0 already makes such a wrapper a thin layer; it is deferred, not required.

  2. Minor naming. data-read is overloaded (it accepts either a collection /index or a feedback /annotations/), and read vs. publish are two separate URLs. Clearer names (e.g. data-aggregate / data-source) or a single data-server convention with derived paths could be cut in a future major.

Bottom line: the dead-simple ideal โ€” npm add @freedback/widgets, import, drop the tag into .tsx, observe the events โ€” works as of 1.0. The only remaining stretch goal is the optional @freedback/react wrapper.