← Blog9 min read#qr code#react#camera

QR Code Scanner in React: Camera Access, Decode, Error Handling

Build a real QR code scanner in React using the MediaStream API and ZXing - camera access, live decode, error states, and mobile gotchas covered.

smartphone camera scanning a QR code on a white label

What You're Actually Building

A QR code scanner in React isn't one thing - it's three separate problems bolted together: getting camera frames, decoding those frames into a string, and handling the dozen ways that can fail. Most tutorials cover step one and ghost you at step three. This one doesn't.

The stack we're using is react@18, the browser's built-in getUserMedia API (no wrappers), and @zxing/browser v0.1.5 for decode. ZXing is battle-tested, the JS port is actively maintained, and it supports QR, DataMatrix, Code128, and everything else you'd need. Honestly, it's one of the few decode libraries worth reaching for in 2026.

Quick aside: you could also use html5-qrcode or react-qr-reader, both of which wrap ZXing anyway. For anything beyond a hobby project, going direct to the underlying library gives you more control over decode frequency and error recovery - and fewer mystery abstractions to debug at 2am.

The finished component will: request the rear camera by default (front camera fallback), render a live video feed inside a <video> element, poll for QR data on every animation frame, and surface structured errors your UI can actually respond to.

Camera Access with getUserMedia

First thing: navigator.mediaDevices.getUserMedia is only available on HTTPS (or localhost). Ship this over plain HTTP in production and you'll get a silent undefined - no error, just no camera. That's bitten a lot of people.

The constraint object is where most tutorials oversimplify. Passing { video: true } works on desktop. On mobile you almost always want the rear camera, which means specifying facingMode: 'environment'. If that fails (some older Android browsers pretend it doesn't exist), fall back to { video: true } silently.

async function startCamera(videoEl: HTMLVideoElement) {
  const constraints: MediaStreamConstraints = {
    video: { facingMode: { ideal: 'environment' } },
    audio: false,
  };

  try {
    const stream = await navigator.mediaDevices.getUserMedia(constraints);
    videoEl.srcObject = stream;
    await videoEl.play();
    return stream;
  } catch (err) {
    if (err instanceof DOMException && err.name === 'OverconstrainedError') {
      // rear camera not available - try any camera
      const fallback = await navigator.mediaDevices.getUserMedia({ video: true });
      videoEl.srcObject = fallback;
      await videoEl.play();
      return fallback;
    }
    throw err;
  }
}

Worth noting: call videoEl.play() yourself after setting srcObject. Some browsers - Firefox especially - won't auto-play even with autoPlay on the element. The await matters; decoding before the video is actually playing gives you empty frames.

One more thing - always store the returned MediaStream and stop all tracks on unmount. Leaving the camera running after the component is gone is an obvious privacy issue, and Chrome will show the red indicator dot permanently until the tab is closed.

Decoding QR Frames with ZXing

ZXing's BrowserQRCodeReader can take a video element and handle the polling loop internally, but I prefer using decodeFromCanvas manually with requestAnimationFrame. It gives you explicit control over when decoding happens, and you can throttle it to every 150ms on low-end devices without fighting the library.

import { BrowserQRCodeReader, Result } from '@zxing/browser';

const reader = new BrowserQRCodeReader();

function decodeLoop(
  videoEl: HTMLVideoElement,
  canvas: HTMLCanvasElement,
  onResult: (text: string) => void,
  onError: (err: Error) => void
) {
  const ctx = canvas.getContext('2d')!;
  let rafId: number;
  let lastDecode = 0;

  async function tick(ts: number) {
    if (ts - lastDecode > 150) {
      lastDecode = ts;
      canvas.width = videoEl.videoWidth;
      canvas.height = videoEl.videoHeight;
      ctx.drawImage(videoEl, 0, 0);
      try {
        const result: Result = await reader.decodeFromCanvas(canvas);
        onResult(result.getText());
      } catch {
        // NotFoundException is normal when no QR is in frame - ignore it
      }
    }
    rafId = requestAnimationFrame(tick);
  }

  rafId = requestAnimationFrame(tick);
  return () => cancelAnimationFrame(rafId);
}

That NotFoundException swallow is intentional. ZXing throws whenever it can't find a code in the frame - that's not an error, that's just "no QR visible yet". You only want to surface real errors: decode failures on a partially visible code, or image corruption. In practice, you can check err.name === 'NotFoundException' and drop those silently while re-throwing everything else.

The canvas approach also means you can resize the decode surface independently of the displayed video. A 640x480 decode canvas is usually enough - going larger just slows down the decode with diminishing returns on QR recognition accuracy.

Building the React Component

Here's the full component wired up. It manages three pieces of state: status (idle | requesting | scanning | error), result (the decoded string), and errorMessage. Clean separation means your UI can render meaningfully at every stage.

import { useEffect, useRef, useState } from 'react';
import { BrowserQRCodeReader } from '@zxing/browser';

type Status = 'idle' | 'requesting' | 'scanning' | 'error';

export function QRScanner({ onScan }: { onScan: (text: string) => void }) {
  const videoRef = useRef<HTMLVideoElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const streamRef = useRef<MediaStream | null>(null);
  const [status, setStatus] = useState<Status>('idle');
  const [error, setError] = useState<string | null>(null);
  const reader = useRef(new BrowserQRCodeReader());

  useEffect(() => {
    let stop: (() => void) | null = null;

    async function init() {
      setStatus('requesting');
      try {
        const video = videoRef.current!;
        const canvas = canvasRef.current!;
        const stream = await startCamera(video);
        streamRef.current = stream;
        setStatus('scanning');
        stop = decodeLoop(
          video,
          canvas,
          (text) => {
            onScan(text);
          },
          (err) => {
            setError(err.message);
            setStatus('error');
          }
        );
      } catch (err) {
        const msg = err instanceof Error ? err.message : 'Camera error';
        setError(msg);
        setStatus('error');
      }
    }

    init();

    return () => {
      stop?.();
      streamRef.current?.getTracks().forEach((t) => t.stop());
    };
  }, [onScan]);

  return (
    <div style={{ position: 'relative', width: 320, height: 320 }}>
      <video
        ref={videoRef}
        muted
        playsInline
        style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 12 }}
      />
      <canvas ref={canvasRef} style={{ display: 'none' }} />
      {status === 'requesting' && (
        <div style={overlayStyle}>Requesting camera…</div>
      )}
      {status === 'error' && (
        <div style={{ ...overlayStyle, color: '#f87171' }}>{error}</div>
      )}
    </div>
  );
}

const overlayStyle: React.CSSProperties = {
  position: 'absolute', inset: 0, display: 'flex',
  alignItems: 'center', justifyContent: 'center',
  background: 'rgba(0,0,0,0.6)', color: '#fff',
  borderRadius: 12, fontSize: 14,
};

Notice playsInline on the video element. Without it, iOS Safari hijacks the video into fullscreen and your overlay UI disappears entirely. It's a single attribute that affects every iPhone user - don't skip it.

The onScan callback in the dependency array means the effect re-runs if the parent re-renders with a different function reference. Wrap your callback in useCallback upstream to avoid spinning up a new camera session on every parent render.

That said, the visual design here is minimal on purpose. If you want a viewfinder overlay with animated corners or a glassmorphism card container around the scanner, those are CSS additions that don't touch the decode logic at all. Keeping them separate makes the scanner reusable.

Error Handling You Actually Need

Camera errors split into two categories: permission errors and device errors. Treat them differently in your UI - they require different actions from the user.

Permission errors come from NotAllowedError (user denied) and SecurityError (wrong origin, usually HTTP instead of HTTPS). For NotAllowedError, show a message that tells the user *how* to re-enable camera access - just saying "camera blocked" isn't enough. Link them to the browser lock icon. For SecurityError, that's a deploy problem, not a user problem.

function classifyError(err: unknown): string {
  if (!(err instanceof DOMException)) return 'Unknown camera error';
  switch (err.name) {
    case 'NotAllowedError':
      return 'Camera access denied. Click the lock icon in your browser\'s address bar to allow it.';
    case 'NotFoundError':
      return 'No camera found on this device.';
    case 'NotReadableError':
      return 'Camera is in use by another app. Close other tabs or apps and try again.';
    case 'OverconstrainedError':
      return 'Rear camera not available - switching to front camera.';
    case 'SecurityError':
      return 'Camera access requires a secure (HTTPS) connection.';
    default:
      return `Camera error: ${err.message}`;
  }
}

Look, the NotReadableError case is the most common one nobody handles. On Android, if the camera is already being used by another browser tab (or the native camera app), you get this error and the user has no idea what happened. That message above is specific enough to actually help.

One more thing - add a retry button that re-runs the init function. Don't force a page reload. The user might have just denied by accident, gone to settings and re-enabled camera access, and now needs to retry without losing whatever form state they had above the scanner.

Mobile Gotchas and Performance

Mobile is where QR scanners get humbling. A few things that will catch you off guard if you haven't built one before.

On iOS 16+, getUserMedia in a cross-origin iframe is blocked by default - even if the parent page grants camera permissions. If you're embedding the scanner in an iframe, you need the allow="camera" attribute on the iframe element. Obvious in hindsight, maddening to debug.

The 150ms decode throttle mentioned earlier matters on lower-end Android devices. Decoding on every animation frame (roughly every 16ms at 60fps) will peg the CPU and cause the video feed itself to stutter. At 150ms you're still scanning fast enough that it feels instant to a human holding a QR code up. Quick aside: ZXing's decodeFromCanvas is synchronous-ish but still takes 20–80ms depending on image complexity, so you're not gaining much by going faster anyway.

// Good: size the canvas relative to actual video dimensions, not display size
canvas.width = videoEl.videoWidth;   // actual pixel data
canvas.height = videoEl.videoHeight;
ctx.drawImage(videoEl, 0, 0);
// Bad: use 320x240 fixed - low-res cameras on older phones will be fine,
// but 4K cameras on newer phones will be working from a downscaled mess

Autoplay policies on Safari require a user gesture before a video with audio can play - but since we're setting muted, this usually isn't an issue. Still worth setting muted explicitly in both the JSX attribute and on the videoEl DOM node directly, because React's muted prop has historically had a bug where it doesn't reflect to the DOM attribute in some versions. Set both.

In terms of styling the 320px container I used above: that's a sensible default but not a hard rule. A QR scanner at 200x200 pixels display size still decodes fine because the canvas is capturing the native video resolution regardless of what CSS does to the element. You can browse components for card containers that work well around constrained aspect-ratio elements.

Testing Without a QR Code

Testing the decode path without pointing your laptop camera at a physical QR code is annoying. Here's the pattern I use: mock getUserMedia in Jest with a 1x1 black canvas stream, test the error classification logic unit-style, and save the actual camera integration for Playwright tests where you can inject a video feed.

// Vitest / Jest - mock getUserMedia to avoid JSDOM camera errors
beforeAll(() => {
  Object.defineProperty(navigator, 'mediaDevices', {
    value: {
      getUserMedia: vi.fn().mockResolvedValue({
        getTracks: () => [{ stop: vi.fn() }],
      }),
    },
  });
});

it('classifies NotAllowedError correctly', () => {
  const err = new DOMException('Permission denied', 'NotAllowedError');
  expect(classifyError(err)).toMatch(/lock icon/);
});

For decode logic specifically, you can point ZXing at a static image URL by using decodeFromImageUrl in a test. Generate a QR code with any online tool, host it in your test fixtures folder, and run a real decode against it. That validates the full decode pipeline without camera hardware.

Honestly, end-to-end testing a QR scanner with real video is difficult enough that most teams skip it and rely on manual QA on physical devices. That's fine - just make sure your error handling logic has decent unit coverage so the failure modes are predictable. If you're building something more production-critical, Playwright's page.grantPermissions(['camera']) combined with a synthetic video source gets you pretty far.

FAQ

Does getUserMedia work in all browsers?

Yes, all modern browsers including Safari 15.4+ on iOS. The catch is it requires HTTPS - it silently fails on plain HTTP, which trips up a lot of people in staging environments.

Which ZXing package should I install?

Use @zxing/browser (not @zxing/library directly). It's the browser-specific wrapper that handles canvas and video elements. Run npm install @zxing/browser.

How do I stop the camera when the component unmounts?

Store the MediaStream in a ref, then call stream.getTracks().forEach(t => t.stop()) in your useEffect cleanup function. Don't skip this - browsers will keep the camera indicator active.

Can I scan QR codes from an uploaded image instead of the camera?

Yes. Use BrowserQRCodeReader.decodeFromImageUrl(url) or draw the image to a canvas and call decodeFromCanvas. No camera permission needed for this path.

Free components in 41 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

React Architecture & Patterns: The Complete 2026 Guide15 Custom React Hooks That Will Save You Hundreds of LinesSupabase + React in 2026: Auth, Realtime, Storage From ScratchDrag-and-Drop Kanban in React: dnd-kit Full Implementation