Camera-Based Barcode Scanning in React

Food Tracker (React + Express + Prisma)

Chapter 4 · Camera-Based Barcode Scanning in React

This is the one chapter in this course with no Express, no Prisma, and no database involvement of any kind — everything here runs entirely in the browser. It's also the third time this exact component has been built across the Food Tracker family: Food Tracker (React + Firebase)'s own Chapter 4 built it first, Food Tracker (React + Express)'s own Chapter 4 reused it unchanged, and what follows here is that same code again, unmodified a second time.

Requesting Camera Access

const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" }, });

facingMode: "environment" requests the rear camera on a phone — the one actually useful for scanning a product. getUserMedia also only works over HTTPS (or localhost during development) — worth knowing now, and something to check specifically once Chapter 11 deploys this app for real.

Decoding Barcodes From Video Frames

Two genuinely different approaches exist. The native browser BarcodeDetector API is fast and built in — but as of general browser support, it's available in Chrome/Edge on Android and desktop, and not in Safari on iOS. A JS library like ZXing (@zxing/browser) works everywhere, at some added CPU cost, since it decodes frames in pure JavaScript rather than using a native implementation. The practical pattern: try BarcodeDetector where it exists, fall back to ZXing where it doesn't.

import { useEffect, useRef } from "react"; function useBarcodeScanner(onDetected) { const videoRef = useRef(null); useEffect(() => { let stream; let stopped = false; async function start() { stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" }, }); videoRef.current.srcObject = stream; await videoRef.current.play(); if ("BarcodeDetector" in window) { const detector = new BarcodeDetector({ formats: ["ean_13", "upc_a"] }); const scan = async () => { if (stopped) return; const barcodes = await detector.detect(videoRef.current); if (barcodes.length > 0) { onDetected(barcodes[0].rawValue); return; } requestAnimationFrame(scan); }; scan(); } else { // fall back to ZXing's BrowserMultiFormatReader here } } start(); return () => { stopped = true; stream?.getTracks().forEach((track) => track.stop()); }; }, [onDetected]); return videoRef; }

Note what this hook does not import, call, or reference anywhere: no fetch to a specific URL, no ORM, no database client of any kind, nothing at all. It only calls one thing — onDetected(barcode) — a plain callback prop the parent supplies. That single design decision is exactly what makes reusing it unchanged possible, for a third time in a row.

Wiring It to This Course's Own Backend

Everything above this point is identical, character for character, to both sibling courses. Here's the parent component that wires it up:

function ScanScreen() { const handleDetected = async (barcode) => { const response = await fetch(`/api/lookup/${barcode}`); // Chapter 3's Express route const result = await response.json(); // ...hand result to the add-item flow, Chapter 5 }; const videoRef = useBarcodeScanner(handleDetected); return

Compare this to Food Tracker (React + Firebase)'s own handleDetected, which called a Cloud Function instead of a plain fetch — the entire difference between those two courses' use of this component came down to that one line. Compare it instead to Food Tracker (React + Express)'s own handleDetected, and there's no difference at all: the exact same fetch("/api/lookup/${barcode}") call, because Chapter 3's route path never changed between the two courses — only what happens inside that route, behind the same URL, changed from raw SQL to Prisma.

The point of building the identical component a third time
Three Food Tracker courses now share this exact hook: one calling a Cloud Function, two calling the identical REST endpoint over two completely different data-access technologies underneath it. The lesson from the sibling course still holds and gets sharper here — a well-designed frontend component doesn't need to know or care what's on the other end of its one callback prop, and it turns out it doesn't even need to know whether that server is talking to the database in raw SQL or through an ORM. The backend's own internal implementation is a genuinely swappable detail this component was never built around in the first place.
Desktop testing doesn't tell the whole story
A laptop webcam is a poor stand-in for the real experience — no autofocus hunting, no holding a curved product steady, none of the resolution constraints a phone camera actually has. Test on a real phone against an HTTPS URL (a tunneling tool like ngrok during local development) before trusting that scanning "works."
Testing only in Chrome hides a real bug
If the ZXing fallback branch is left as a stub (as it is above, for brevity) rather than actually implemented, the scanner will work perfectly in Chrome-based browser testing and then silently fail for every iPhone Safari user — a large share of any real phone-camera app's actual audience. "BarcodeDetector" in window being false doesn't throw an error; it just quietly does nothing unless the fallback path is genuinely built and tested, not just sketched in a comment.

Where This Course Is Headed

The add-item flow next — a React form component plus Prisma-backed POST route, taking this scanned barcode's lookup result the rest of the way into the database.

Hands-On Exercises

Exercise 1

Explain why this chapter's ScanScreen needs zero changes — not even the one line that differed between the Firebase and Express siblings — despite this course's own backend switching from raw SQL to Prisma between Chapters 2 and 3.

📄 View solution
Exercise 2

Explain what happens if the useEffect cleanup function omits stream?.getTracks().forEach(track => track.stop()), and why this matters specifically for a scan screen a user might navigate to and away from repeatedly.

📄 View solution
Exercise 3

Explain why leaving the ZXing fallback as an unimplemented stub could pass all of a developer's own testing and still be a real bug in production. Which users specifically would be affected, and why wouldn't Chrome-based testing ever catch it?

📄 View solution

Chapter 4 Quick Reference

  • This component is reused a third time, not rebuilt — identical to both Food Tracker (React + Firebase) and Food Tracker (React + Express) Chapter 4's own useBarcodeScanner hook
  • facingMode: "environment" — the rear camera, not the front-facing one
  • BarcodeDetector — native, fast, Chrome/Edge/Android; not on Safari/iOS — needs a ZXing fallback
  • Cleanup — always stop every track from the stream in the effect's cleanup function, or the camera stays on after unmount
  • What differs from the raw-SQL sibling here: nothing — same URL, same route path, same hook, since Prisma changed what happens inside Chapter 3's route, not its shape
  • Next chapter: Building the Add-Item Flow