Skip to content

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.
Terminal window
bun add html5-qrcode

The component expects React to already be present in your app.

Fetch a short-lived token from your own backend, then render the scanner:

IdentifyPage.tsx
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).

<DustScanner
operation="bind"
bearerToken={token}
organizationId={organizationId}
threadId={threadId}
tagDescription="Receiving scan"
onResult={setResult}
/>
<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.

DustScanner.tsx — click to expand
DustScanner.tsx
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
scanner.css
.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;
}