Skip to content

How to build a React QR code scanner

A React QR code scanner is a video element, a useEffect that opens the camera with getUserMedia, and a loop that sends frames to a decoder such as zxing-wasm or the browser's BarcodeDetector. The part most examples get wrong is cleanup: stop the camera tracks when the component unmounts.

What you need

You do not need a React-specific scanner package. The browser APIs are small, and writing the hook yourself means you control when the camera turns on and off. If you prefer a component that renders the webcam for you, react-webcam is a common choice, and you still pass its frames to a decoder.

  • React 18 or later, in any setup: Vite, Next.js or similar.
  • A decoder: npm i zxing-wasm for every browser, or BarcodeDetector where the browser has it.
  • HTTPS in production. Browsers only allow camera access on secure pages; localhost works during development.
  • A user action to start the camera, such as a Scan button, so the permission prompt makes sense to the user.

The camera hook, step by step

Keep the scanning logic in a custom hook, say useQrScanner, that takes a ref to a video element and a callback. The steps below are the lines that matter; wrap the async work in an inner function, because an effect callback cannot be async itself.

  1. Render the video: <video ref={videoRef} playsInline muted /> (playsInline stops iPhones going full screen).
  2. In useEffect, set let cancelled = false; let stream; let timer; at the top.
  3. Open the camera: stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
  4. If cancelled became true while waiting, stop the tracks at once and return. This matters in React Strict Mode, which mounts, unmounts and remounts effects in development.
  5. Attach it: videoRef.current.srcObject = stream; await videoRef.current.play();
  6. Start a loop with setTimeout or requestAnimationFrame that decodes a frame every 150 to 250 ms.
  7. Return a cleanup function: cancelled = true; clearTimeout(timer); stream?.getTracks().forEach((t) => t.stop());

Decoding frames with zxing-wasm or BarcodeDetector

Create one canvas outside the loop, with getContext('2d', { willReadFrequently: true }). On each tick, draw the frame and read its pixels: ctx.drawImage(video, 0, 0, w, h); const frame = ctx.getImageData(0, 0, w, h);

Then decode with zxing-wasm: const results = await readBarcodes(frame, { formats: ['QRCode'] }); and use results[0]?.text. Import readBarcodes from 'zxing-wasm/reader'. Add 'EAN13', 'Code128' and other formats to the list to make it a React barcode scanner too, or leave formats out to read everything.

Where BarcodeDetector exists, you can skip the canvas: create the detector once with new BarcodeDetector({ formats: ['qr_code'] }) and call detector.detect(videoRef.current). Check 'BarcodeDetector' in window first, since only Chromium-based browsers on some platforms have it.

zxing-wasm loads a .wasm file on first use, from a CDN by default. Serve it from your own site with prepareZXingModule and a locateFile override, so the scanner works offline and makes no third-party request.

Passing results out without restarting the camera

If the hook's effect depends on the onResult callback, every parent render creates a new function, and the effect tears down and reopens the camera. Store the callback in a ref instead: const onResultRef = useRef(onResult); and update onResultRef.current = onResult on each render. The effect then runs once, with an empty dependency array, and always calls the latest callback.

Ignore repeats: keep the last value and its time in a ref, and skip the same value for a second or two. Otherwise one QR code can set state dozens of times.

Permissions and errors

getUserMedia rejects with a NotAllowedError when the user refuses or has blocked the camera for your site, and a NotFoundError when there is no camera. Catch both, show a short message, and offer an image upload instead. Once a person has blocked the camera, only they can change it in the browser's site settings.

For uploads, an input with type file and accept image/* covers phones and desktops. Pass the File straight to readBarcodes, or create an ImageBitmap for BarcodeDetector. Listening for paste events on the page also lets people press Ctrl+V or Cmd+V with a screenshot.

Next.js and server rendering

navigator and the camera only exist in the browser. In the Next.js App Router, mark the scanner file with 'use client', and only touch navigator.mediaDevices inside useEffect or an event handler, never during render. If a library touches window when imported, load the component with next/dynamic and ssr: false.

zxing-wasm is also happy in a Web Worker. Posting ImageData to a worker keeps decoding off the main thread, so the video and your UI stay smooth on slower phones.

Before you ship

  • Turn the camera off when the scanner is hidden, not just when it unmounts, so the camera light goes off.
  • Show the decoded text or link and let the user choose to open it. Never redirect automatically.
  • Test on an iPhone in Safari and an Android phone in Chrome; they differ in camera choice and autoplay rules.
  • Offer a torch or zoom control only if the track reports support for it through getCapabilities().

Read next

FAQ

Questions

What is the best React QR scanner library?

You can build one with getUserMedia and zxing-wasm in a small hook, which reads QR codes and barcodes in every modern browser. react-webcam helps if you want a ready webcam component.

How do I stop the camera when my React component unmounts?

Return a cleanup function from useEffect that calls getTracks() on the stream and stops each track.

Why does my React barcode scanner open the camera twice in development?

React Strict Mode mounts effects twice in development. Make cleanup stop the stream and ignore a stream that arrives after cleanup has run.

Can a React webcam barcode scanner work on iPhone?

Yes, in Safari over HTTPS, with playsInline and muted on the video element and a user tap before the camera starts.

Does the image leave the device?

No. zxing-wasm and BarcodeDetector decode in the browser, unless your own code uploads frames.