Building a QR code scanner for the browser sounds straightforward. Then you try it and hit three problems fast:
- Camera access requires specific permissions handling
- The scanning library manipulates the DOM directly — React doesn't like that
- Stopping the scanner at the wrong moment throws uncatchable errors
I built a QR Code Scanner with camera feed and image upload support using html5-qrcode. Here's what I ran into and how I fixed each issue.
Choosing html5-qrcode
There are a few options for browser QR scanning:
- jsQR — pure JS, manual camera loop, no built-in UI
- ZXing — Java port, heavier bundle
- html5-qrcode — handles camera, file scanning, and UI scaffolding out of the box
html5-qrcode won because it abstracts the camera permission flow and getUserMedia loop. Less code to own.
npm install html5-qrcode
Basic Architecture
Two scan modes: image upload and live camera. Both use the same Html5Qrcode instance but different methods.
import { Html5Qrcode } from "html5-qrcode";
// Image scanning
const scanner = new Html5Qrcode("container-id");
const result = await scanner.scanFile(file, true);
// Camera scanning
await scanner.start(
{ facingMode: "environment" },
{ fps: 10, qrbox: { width: 250, height: 250 } },
(decoded) => { /* success */ },
() => {} // frame failure — ignore
);
Straightforward on paper. The DOM conflicts make it interesting.
Problem 1: React Can't Remove DOM Nodes Owned by html5-qrcode
The first error: Node.removeChild: The node to be removed is not a child of this node.
Here's what happens. You render the camera container conditionally:
{cameraActive && (
<div id="qr-camera-feed" />
)}
html5-qrcode starts injecting <video>, <canvas>, and <div> elements inside #qr-camera-feed. Then when cameraActive becomes false, React tries to remove its version of #qr-camera-feed from the DOM — but the DOM no longer matches React's virtual DOM because the library modified it.
The fix: never conditionally unmount the container. Give the library exclusive DOM ownership by keeping the element mounted and using CSS to hide it instead:
{/* Camera prompt — separate element, not inside the feed div */}
{!cameraActive && (
<div onClick={startCamera} className="...prompt UI...">
Click to start camera
</div>
)}
{/* html5-qrcode owns this div entirely — no React children inside */}
<div
id="qr-camera-feed"
className={cameraActive ? "block" : "hidden"}
/>
Two key changes:
- The "Click to start camera" UI is a separate element — not a child of
#qr-camera-feed - The camera container is always mounted, just hidden with
display: nonewhen inactive
React never tries to remove nodes from inside the container, so no conflict.
Problem 2: "Cannot stop, scanner is not running"
The second error appeared when a QR code was detected. The success callback fired, which called stopCamera(), which called scanner.stop() — but the scanner was still in the middle of processing the frame.
The solution is a ref-based running flag:
const scannerRef = useRef<Html5Qrcode | null>(null);
const scannerRunningRef = useRef(false);
In startCamera, set the flag after scanner.start() resolves:
await scanner.start(
{ facingMode: "environment" },
{ fps: 10, qrbox: { width: 250, height: 250 } },
(decoded) => {
const parsed = parseQrContent(decoded);
// Guard against double-stop
if (scannerRunningRef.current) {
scannerRunningRef.current = false;
scanner.stop().then(() => {
scanner.clear();
scannerRef.current = null;
setCameraActive(false);
setResult(parsed);
}).catch(() => {
scannerRef.current = null;
setCameraActive(false);
setResult(parsed);
});
}
},
() => {}
);
scannerRunningRef.current = true;
And in stopCamera:
const stopCamera = useCallback(async () => {
if (scannerRef.current && scannerRunningRef.current) {
scannerRunningRef.current = false;
try {
await scannerRef.current.stop();
scannerRef.current.clear();
} catch {
// Already stopped
}
scannerRef.current = null;
}
setCameraActive(false);
}, []);
The flag prevents double-stop from both the success callback and any manual "Stop Camera" click. Setting it to false before calling stop() means subsequent calls are no-ops.
Problem 3: Cleanup on Unmount
If the user navigates away while the camera is active, the scanner keeps running. The cleanup effect handles this:
useEffect(() => {
return () => {
if (scannerRef.current && scannerRunningRef.current) {
scannerRunningRef.current = false;
scannerRef.current.stop().then(() => {
scannerRef.current?.clear();
scannerRef.current = null;
}).catch(() => {
scannerRef.current = null;
});
}
};
}, []);
The empty dependency array means this only runs on mount/unmount. Always async — stop() returns a Promise and we shouldn't block cleanup.
SSR: html5-qrcode Is Browser-Only
html5-qrcode references navigator, window, and the DOM at import time. It crashes on the server.
// QrCodeScannerLoader.tsx
const QrCodeScanner = dynamic(
() => import("./QrCodeScanner").then((mod) => mod.QrCodeScanner),
{ ssr: false }
);
ssr: false ensures the component only hydrates in the browser. The page wrapper (SEO content, schema markup) still renders server-side.
Image Upload Scanning
For the image upload mode, html5-qrcode provides scanFile():
const handleImageFile = useCallback(async (file: File) => {
setScanning(true);
try {
const scanner = new Html5Qrcode("qr-scanner-hidden");
const decoded = await scanner.scanFile(file, true);
setResult(parseQrContent(decoded));
scanner.clear();
} catch {
setError("No QR code found. Try a clearer image.");
} finally {
setScanning(false);
}
}, []);
Note "qr-scanner-hidden" — a separate hidden div from the camera container. scanFile() also manipulates the DOM temporarily, so it gets its own isolated element:
<div id="qr-scanner-hidden" className="hidden" />
The true parameter in scanFile(file, true) enables verbose mode for better detection on low-quality images.
Parsing QR Content Types
Raw QR code strings follow format conventions. A WiFi QR code looks like:
WIFI:T:WPA;S:NetworkName;P:Password;;
A geo coordinate looks like:
geo:37.7749,-122.4194
Rather than showing users the raw string, parse it into structured data:
function parseQrContent(text: string): ParsedResult {
const t = text.trim();
if (/^https?:\/\//i.test(t))
return { type: "url", raw: t, data: { url: t } };
const wifiMatch = t.match(
/^WIFI:(?:T:(.*?);)?(?:S:(.*?);)?(?:P:(.*?);)?(?:H:(.*?);)?;?$/i
);
if (wifiMatch)
return { type: "wifi", raw: t, data: {
encryption: wifiMatch[1] || "",
ssid: wifiMatch[2] || "",
password: wifiMatch[3] || "",
}};
if (/^tel:/i.test(t))
return { type: "phone", raw: t, data: { phone: t.replace(/^tel:/i, "") } };
if (/^mailto:/i.test(t)) {
const url = new URL(t);
return { type: "email", raw: t, data: {
email: url.pathname,
subject: url.searchParams.get("subject") || "",
}};
}
if (/^geo:/i.test(t)) {
const [lat, lng] = t.replace(/^geo:/i, "").split(",");
return { type: "geo", raw: t, data: { lat, lng } };
}
if (/^BEGIN:VCARD/i.test(t)) {
return { type: "vcard", raw: t, data: {
name: t.match(/FN:(.*)/i)?.[1] || "",
phone: t.match(/TEL.*?:(.*)/i)?.[1] || "",
email: t.match(/EMAIL.*?:(.*)/i)?.[1] || "",
}};
}
return { type: "text", raw: t, data: { text: t } };
}
This lets you show WiFi credentials in a readable grid, render URLs as clickable links, and add a "Open in Google Maps" button for geo coordinates — instead of dumping a raw string at the user.
The Final Component Structure
QrCodeScannerLoader.tsx — dynamic import wrapper (ssr: false)
QrCodeScanner.tsx — main component
├── #qr-scanner-hidden — isolated div for scanFile()
├── Mode toggle — Image Upload / Camera Scan
├── Image upload zone — drag & drop
├── Camera prompt — shown when !cameraActive
├── #qr-camera-feed — always mounted, hidden/shown via CSS
└── Result display — parsed content with type badge
Key Takeaways
- Never conditionally unmount a container that a DOM-manipulating library owns — use CSS hide/show instead
- Track scanner state with a ref — prevents double-stop race conditions
- Give image scanning its own isolated container — separate from the camera feed
-
Parse QR formats — users shouldn't have to decode
WIFI:T:WPA;S:...themselves -
Always clean up on unmount — camera streams keep running after navigation without explicit
stop()
The tool is live at ultimatetools.io/tools/misc-tools/qr-code-scanner/ if you want to see it in action.
Part of Ultimate Tools — free, privacy-first browser tools built with Next.js and TypeScript.
Top comments (0)