Skip to content

Implementing a Barcode Gun Scanner with React

Context

Barcode scanners are commonly used in various industries for scanning barcodes quickly and efficiently.

Imagine a UX in which users can:

// this is how you can "Simulate" barcode gun scanning in a cypress test.

cy.get("body")
  .type(product.barcode)
  .trigger("keydown", { key: "Enter", which: 13 });

Now imagine a third scenario:

With this we can call the Unload API directly to speed up the unloading process or just open an unload modal everywhere and just unload our products! To cover this UX we need to develop a listener that does the barcode scanning process on a low level.

Implementing the barcode gun listener

Configuration

Before we dive into the implementation details, let’s take a look at the configuration options for our barcode scanner

import { useCallback, useEffect, useState } from "react";
import * as Beep from "./beep.mp3"; // Beep scanning sound

interface BufferCharacter {
  time: number;
  char: string;
}

interface BarcodeScannerConfig {
  readonly timeToEvaluate?: number;
  readonly averageWaitTime?: number;
  readonly startCharacter?: ReadonlyArray<string>;
  readonly endCharacter?: ReadonlyArray<string>;
  readonly onComplete: (code: string) => void;
  readonly onError?: (error: string) => void;
  readonly minLength?: number;
  readonly ignoreIfFocusOn?: Node;
  readonly stopPropagation?: boolean;
  readonly preventDefault?: boolean;
  readonly acousticSignal?: boolean;
}

The useBarcodeScanner hook function

We’ll implement the barcode scanner as a custom React hook.

/**
 * @info
 * https://it.wikipedia.org/wiki/Global_Trade_Item_Number
 */
export const useBarcodeScanner = ({
  timeToEvaluate = 100,
  averageWaitTime = 50,
  startCharacter = [],
  endCharacter = ["escape", "enter"],
  onComplete,
  onError,
  minLength = 1,
  ignoreIfFocusOn,
  stopPropagation = false,
  preventDefault = false,
  acousticSignal = true,
}: BarcodeScannerConfig): void => {
  const [buffer, setBuffer] = useState<ReadonlyArray<BufferCharacter>>([]);
  const [timeoutState, setTimeoutState] = useState<number>(0);

  const clearBuffer = (): void => {
    setBuffer([]);
  };
  const evaluateBuffer = useCallback((): void => {
    clearTimeout(timeoutState);

    const sum = buffer
      .map(({ time }, k, arr) => (k > 0 ? time - arr[k - 1].time : 0))
      .slice(1)
      .reduce((total, delta) => total + delta, 0);

    const avg = sum / (buffer.length - 1);

    const code = buffer
      .slice(startCharacter.length > 0 ? 1 : 0)
      .map(({ char }) => char)
      .join("");

    const bufferLength = buffer.slice(startCharacter.length > 0 ? 1 : 0).length;

    if (avg <= averageWaitTime && bufferLength >= minLength) {
      if (isNaN(code as unknown as number)) return onError?.(code);
      if (acousticSignal) {
        const audio = new Audio(Beep);
        audio.play();
      }

      onComplete(code);
    } else {
      if (avg <= averageWaitTime && !!onError) {
        onError(code);
      }
    }
    clearBuffer();
  }, [
    acousticSignal,
    averageWaitTime,
    buffer,
    minLength,
    onComplete,
    onError,
    startCharacter.length,
    timeoutState,
  ]);

  const onKeyDown = useCallback(
    (event: KeyboardEvent) => {
      if (event.currentTarget === ignoreIfFocusOn) return; // REF ?

      const kbdKey = event.key.toLowerCase();
      const isLastChar = endCharacter.includes(kbdKey);

      if (isLastChar) evaluateBuffer();

      if (
        !isLastChar &&
        (buffer.length > 0 ||
          startCharacter.includes(kbdKey) ||
          startCharacter.length === 0)
      ) {
        clearTimeout(timeoutState);
        setTimeoutState(
          setTimeout(evaluateBuffer, timeToEvaluate) as unknown as number
        );
        setBuffer(prev => [...prev, { time: performance.now(), char: kbdKey }]);
      }

      if (stopPropagation) event.stopPropagation();

      if (preventDefault) event.preventDefault();
    },
    [
      ignoreIfFocusOn,
      endCharacter,
      buffer,
      startCharacter,
      stopPropagation,
      preventDefault,
      evaluateBuffer,
      timeoutState,
      timeToEvaluate,
    ]
  );

  useEffect(
    () => (): void => {
      clearTimeout(timeoutState);
    },
    [timeoutState]
  );

  useEffect(() => {
    document.addEventListener("keydown", onKeyDown as EventListener);
    return (): void => {
      document.removeEventListener("keydown", onKeyDown as EventListener);
    };
  }, [onKeyDown]);
};

implementing the barcode gun listener

Now that we have our useBarcodeScanner hook, let’s see how we can use it to implement the barcode gun listener in our application:

useBarcodeScanner({
  timeToEvaluate: 800,
  averageWaitTime: 16,
  stopPropagation: true,
  acousticSignal: false,
  onComplete: (
    barcode // fired when detects a valid barcode
  ) =>
    query(
      // product lookup by barcode on the server
      { variables: { q: barcode } },
      {
        onError: console.error,
        onCompleted: handleSelectedProduct; // handle the retrieved product
      }
    ),
});

In this example, we pass the necessary configuration options to the useBarcodeScanner hook. We set timeToEvaluate to 800 milliseconds and averageWaitTime to 16 milliseconds, which determines the threshold for recognizing a valid barcode. When a valid barcode is detected, the onComplete callback is called, and we can perform the desired action, such as querying the server for the product details.