React Scanner
A small React component that calls APID directly. It supports three modes:
- DUST — uploads a DUST scan image to the APID Identifier endpoints.
- Other — uses the device camera to read QR, barcode, and Data Matrix Identifiers.
- Manual — submits QR, barcode, Data Matrix, or NFC Identifiers from a text field.
Install
Section titled “Install”bun add html5-qrcodeThe component expects React to already be present in your app.
bun add react react-dom html5-qrcodeUse it
Section titled “Use it”Fetch a short-lived token from your own backend, then render the scanner:
import { useEffect, useState } from "react";import { DustScanner } from "./DustScanner";import "./scanner.css";
export function IdentifyPage() { const [token, setToken] = useState<string | null>(null);
useEffect(() => { // Your endpoint: exchanges the user's session for a short-lived APID token. fetch("/api/scanner-token") .then((res) => res.json()) .then(({ token }) => setToken(token)); }, []);
if (!token) return <p>Preparing scanner…</p>;
return ( <DustScanner operation="identify" bearerToken={token} organizationId="00000000-0000-0000-0000-000000000000" searchGroupIds={["00000000-0000-0000-0000-000000000000"]} onResult={(result) => console.log("scan result", result)} /> );}searchGroupIds names the Teams whose Identifiers are searched during identify — the prop keeps the API’s legacy group field naming. The optional groupId prop pins the request’s Team context (sent as the Dust-Ctx-Team-Id header).
Bind to a Thread
Section titled “Bind to a Thread”<DustScanner operation="bind" bearerToken={token} organizationId={organizationId} threadId={threadId} tagDescription="Receiving scan" onResult={setResult}/>Verify an Identifier
Section titled “Verify an Identifier”<DustScanner operation="verify" bearerToken={token} organizationId={organizationId} threadId={threadId} verifyTags={[{ tagId: "replace-with-tag-id", tagType: "DUST" }]} onResult={setResult}/>See Identifiers for what each operation returns, and Integrate with DUST Go if your app runs inside the DUST Go mobile browser instead of using the device camera directly.
Component source
Section titled “Component source”DustScanner.tsx — click to expand
import { Html5Qrcode, type Html5QrcodeResult } from "html5-qrcode";import { useCallback, useEffect, useMemo, useRef, useState } from "react";
type ScannerMode = "dust" | "other" | "manual";type ScanOperation = "identify" | "bind" | "verify";type NonDustTagType = "QR" | "BAR_CODE" | "DATA_MATRIX" | "NFC";type TagType = "DUST" | NonDustTagType;
type VerifyTag = { tagId: string; tagType: TagType;};
type DustScannerProps = { apidUrl?: string; bearerToken: string; organizationId: string; groupId?: string; operation: ScanOperation; threadId?: string; verifyTags?: VerifyTag[]; searchGroupIds?: string[]; tagDescription?: string; onResult?: (result: unknown) => void; onError?: (error: Error) => void;};
type Detection = { tagType: NonDustTagType; value: string; format?: string;};
const FORMAT_TO_TAG: Record<string, { tagType: NonDustTagType; format: string }> = { QR_CODE: { tagType: "QR", format: "qrcode" }, DATA_MATRIX: { tagType: "DATA_MATRIX", format: "datamatrix" }, AZTEC: { tagType: "DATA_MATRIX", format: "azteccode" }, MAXICODE: { tagType: "DATA_MATRIX", format: "maxicode" }, EAN_13: { tagType: "BAR_CODE", format: "ean13" }, EAN_8: { tagType: "BAR_CODE", format: "ean8" }, UPC_A: { tagType: "BAR_CODE", format: "upca" }, UPC_E: { tagType: "BAR_CODE", format: "upce" }, CODE_39: { tagType: "BAR_CODE", format: "code39" }, CODE_93: { tagType: "BAR_CODE", format: "code93" }, CODE_128: { tagType: "BAR_CODE", format: "code128" }, ITF: { tagType: "BAR_CODE", format: "interleaved2of5" }, CODABAR: { tagType: "BAR_CODE", format: "rationalizedCodabar" }, PDF_417: { tagType: "BAR_CODE", format: "pdf417" },};
// Manual entry can't know the concrete symbology, so choose a canonical// format from the same vocabulary the camera path uses.const MANUAL_FORMATS: Record<NonDustTagType, string> = { QR: FORMAT_TO_TAG.QR_CODE.format, BAR_CODE: FORMAT_TO_TAG.CODE_128.format, DATA_MATRIX: FORMAT_TO_TAG.DATA_MATRIX.format, NFC: "nfc",};
function normalizeIdentifier(value: string) { const trimmed = value.trim(); if (!trimmed || trimmed.length > 2000) return null;
try { const parsed = JSON.parse(trimmed); if (parsed && typeof parsed === "object" && typeof parsed.id === "string") { return parsed.id.trim() || null; } } catch { // Plain text identifiers are valid. }
return trimmed;}
function detectNonDust(decodedText: string, result: Html5QrcodeResult): Detection | null { const value = normalizeIdentifier(decodedText); const formatName = result.result.format?.formatName?.toUpperCase(); if (!value || !formatName) return null;
const mapped = FORMAT_TO_TAG[formatName]; if (!mapped) return null;
return { tagType: mapped.tagType, value, format: mapped.format, };}
function appendJson(form: FormData, key: string, value: unknown) { if (value === undefined) return; form.set(key, typeof value === "string" ? value : JSON.stringify(value));}
export function DustScanner({ apidUrl = "https://apid.dustid.io", bearerToken, organizationId, groupId, operation, threadId, verifyTags = [], searchGroupIds, tagDescription, onResult, onError,}: DustScannerProps) { const [mode, setMode] = useState<ScannerMode>("dust"); const [manualType, setManualType] = useState<NonDustTagType>("QR"); const [manualValue, setManualValue] = useState(""); const [busy, setBusy] = useState(false); const [message, setMessage] = useState<string | null>(null); const qrRegionId = useMemo(() => `dust-non-dust-scanner-${crypto.randomUUID()}`, []); const qrRef = useRef<Html5Qrcode | null>(null); const lastDetectionRef = useRef<string | null>(null);
const submitForm = useCallback( async (path: string, form: FormData) => { const headers = new Headers({ Authorization: `Bearer ${bearerToken}`, "Dust-Ctx-Org-Id": organizationId, }); if (groupId) headers.set("Dust-Ctx-Team-Id", groupId);
const response = await fetch(`${apidUrl.replace(/\/+$/, "")}${path}`, { method: "POST", headers, body: form, });
const text = await response.text(); const data = text ? JSON.parse(text) : null;
if (!response.ok) { throw new Error(data?.message ?? `APID request failed with ${response.status}`); }
return data; }, [apidUrl, bearerToken, groupId, organizationId], );
const runScan = useCallback( async (scan: { tagType: TagType; data: string | Blob; metadata?: Record<string, unknown> }) => { setBusy(true); setMessage(null);
try { const form = new FormData(); form.set("tagType", scan.tagType); form.set("data", scan.data);
if (operation === "identify") { appendJson(form, "searchGroupIds", searchGroupIds); const result = await submitForm("/api/v1/tags/identify", form); onResult?.(result); setMessage("Identify complete."); return; }
if (!threadId) { throw new Error("threadId is required for bind and verify operations."); }
form.set("threadId", threadId);
if (operation === "bind") { if (tagDescription) form.set("tagDescription", tagDescription);
if (scan.tagType === "DUST") { appendJson(form, "options", { enrollmentSessionId: crypto.randomUUID() }); } else if (scan.metadata) { appendJson(form, "options", { metadata: scan.metadata }); }
const result = await submitForm("/api/v1/tags/bind", form); onResult?.(result); setMessage("Bind complete."); return; }
if (verifyTags.length === 0) { throw new Error("verifyTags is required for verify operations."); }
appendJson(form, "tags", verifyTags); const result = await submitForm("/api/v1/tags/verify", form); onResult?.(result); setMessage("Verify complete."); } catch (error) { const err = error instanceof Error ? error : new Error("Unknown scanner error"); setMessage(err.message); onError?.(err); } finally { setBusy(false); } }, [onError, onResult, operation, searchGroupIds, submitForm, tagDescription, threadId, verifyTags], );
const runScanRef = useRef(runScan); runScanRef.current = runScan; const onErrorRef = useRef(onError); onErrorRef.current = onError;
const handleDustFile = useCallback( async (file: File | null) => { if (!file) return; await runScan({ tagType: "DUST", data: file }); }, [runScan], );
const handleManualSubmit = useCallback(async () => { const value = normalizeIdentifier(manualValue); if (!value) { setMessage("Enter an identifier value."); return; }
await runScan({ tagType: manualType, data: value, metadata: { format: MANUAL_FORMATS[manualType] }, }); setManualValue(""); }, [manualType, manualValue, runScan]);
useEffect(() => { if (mode !== "other") return;
let cancelled = false; const scanner = new Html5Qrcode(qrRegionId, false); qrRef.current = scanner;
// Keep the start promise so cleanup can wait for an in-flight startup // before stopping — otherwise a fast unmount leaves the camera running. const startPromise = scanner .start( { facingMode: "environment" }, { fps: 8, qrbox: { width: 260, height: 260 } }, async (decodedText, result) => { const detection = detectNonDust(decodedText, result); if (!detection) return;
const key = `${detection.tagType}:${detection.format}:${detection.value}`; if (lastDetectionRef.current === key) return; lastDetectionRef.current = key;
await runScanRef.current({ tagType: detection.tagType, data: detection.value, metadata: detection.format ? { format: detection.format } : undefined, });
window.setTimeout(() => { if (lastDetectionRef.current === key) lastDetectionRef.current = null; }, 2000); }, () => {}, ) .catch((error) => { if (!cancelled) { const err = error instanceof Error ? error : new Error("Could not start camera scanner."); setMessage(err.message); onErrorRef.current?.(err); } });
return () => { cancelled = true; qrRef.current = null; void startPromise.then(async () => { if (scanner.isScanning) await scanner.stop().catch(() => undefined); scanner.clear(); }); }; }, [mode, qrRegionId]);
return ( <section className="dust-scanner"> <div className="dust-scanner__modes" role="group" aria-label="Scanner mode"> {(["dust", "other", "manual"] as const).map((nextMode) => ( <button key={nextMode} type="button" aria-pressed={mode === nextMode} onClick={() => setMode(nextMode)} > {nextMode === "dust" ? "DUST" : nextMode === "other" ? "Other" : "Manual"} </button> ))} </div>
{mode === "dust" ? ( <label className="dust-scanner__dropzone"> <span>DUST image scan</span> <input disabled={busy} type="file" accept="image/*" capture="environment" onChange={(event) => void handleDustFile(event.currentTarget.files?.[0] ?? null)} /> </label> ) : null}
{mode === "other" ? <div id={qrRegionId} className="dust-scanner__camera" /> : null}
{mode === "manual" ? ( <div className="dust-scanner__manual"> <select value={manualType} disabled={busy} aria-label="Identifier type" onChange={(event) => setManualType(event.currentTarget.value as NonDustTagType)} > <option value="QR">QR</option> <option value="BAR_CODE">Barcode</option> <option value="DATA_MATRIX">Data Matrix</option> <option value="NFC">NFC</option> </select> <input data-manual-identifier-input aria-label="Identifier value" value={manualValue} disabled={busy} placeholder="Identifier value" onChange={(event) => setManualValue(event.currentTarget.value)} onKeyDown={(event) => { if (event.key === "Enter") void handleManualSubmit(); }} /> <button type="button" disabled={busy} onClick={() => void handleManualSubmit()}> Submit </button> </div> ) : null}
{message ? <p role="status">{message}</p> : null} {busy ? <p>Processing scan...</p> : null} </section> );}scanner.css — click to expand
.dust-scanner { display: grid; gap: 1rem; max-width: 42rem;}
.dust-scanner__modes { display: inline-flex; width: fit-content; gap: 0.25rem; border: 1px solid #d4d4d8; border-radius: 8px; padding: 0.25rem;}
.dust-scanner__modes button { border: 0; border-radius: 6px; background: transparent; padding: 0.45rem 0.75rem; cursor: pointer;}
.dust-scanner__modes button[aria-pressed="true"] { background: #111827; color: white;}
.dust-scanner__dropzone,.dust-scanner__camera,.dust-scanner__manual { border: 1px solid #d4d4d8; border-radius: 8px; padding: 1rem;}
.dust-scanner__dropzone { display: grid; gap: 0.75rem;}
.dust-scanner__camera { min-height: 320px;}
.dust-scanner__manual { display: flex; flex-wrap: wrap; gap: 0.5rem;}
.dust-scanner__manual input { min-width: min(100%, 18rem); flex: 1;}