How to Build a Barcode and QR Code Scanner in Remix

Remix is a full stack web framework that lets you focus on the user interface and work back through web standards to deliver a fast, slick, and resilient user experience. It has been acquired by Shopify and is the recommended framework in React’s docs.

In this article, we are going to build a barcode and QR code scanner using Remix. Dynamsoft Camera Enhancer is used to access the camera in browsers and Dynamsoft Barcode Reader is used to read barcodes from the camera video frames.

Online demo

To illustrate Remix’s features, the demo scans the ISBN barcodes, gets the book info, and saves them to a PostgreSQL database with Prisma as the ORM.

New Book Record Demo Video:

Book Record Editing Demo Video:

Getting started with Dynamsoft Barcode Reader

Build a Barcode and QR Code Scanner with Remix in Steps

Let’s do this in steps.

New Project

Create a new Remix project named barcode-scanner:

npx create-remix@latest barcode-scanner

Then, we can run the following to test it:

cd barcode-scanner
npm install
npm run dev

Install Dependencies

Install Dynamsoft Barcode Reader and Dynamsoft Camera Enhancer.

npm install dynamsoft-javascript-barcode@9.6.31 dynamsoft-camera-enhancer@3.3.8

PS: Add "type":"module" to their package.json if the compiler fails to recognize the ES modules.

Create a Barcode Scanner React Component

Next, let’s create a barcode scanner component under app/components/BarcodeScanner.tsx that can open the camera and scan barcodes from the camera video frames.

  1. Write the basic content of the component:

    import {CameraEnhancer,PlayCallbackInfo} from "dynamsoft-camera-enhancer";
    import {BarcodeReader,TextResult}from "dynamsoft-javascript-barcode";
    import React from "react";
    import { ReactNode } from "react";
       
    export interface ScanRegion {
      left:number;
      top:number;
      right:number;
      bottom:number;
    }
       
    export interface ScannerProps{
      isActive?: boolean;
      children?: ReactNode;
      interval?: number;
      license?: string;
      scanRegion?: ScanRegion;
      onInitialized?: (enhancer:CameraEnhancer,reader:BarcodeReader) => void;
      onScanned?: (results:TextResult[]) => void;
      onPlayed?: (playCallbackInfo: PlayCallbackInfo) => void;
      onClosed?: () => void;
    }
    
    const BarcodeScanner = (props:ScannerProps): React.ReactElement => {
      return (
      )
    }
    
    export default BarcodeScanner;
    

    The component has several props to control the scanner and get the barcode results.

  2. Define the container for the scanner. It has a video container and other helper controls used by Dynamsoft Camera Enhancer. Here, we use the relative position so that we can customize its position in the parent component.

    const container = React.useRef(null);
    return (
      <div ref={container} style={{ position:"relative", width:"100%", height:"100%" }}>
        <svg className="dce-bg-loading"
         viewBox="0 0 1792 1792">
         <path d="M1760 896q0 176-68.5 336t-184 275.5-275.5 184-336 68.5-336-68.5-275.5-184-184-275.5-68.5-336q0-213 97-398.5t265-305.5 374-151v228q-221 45-366.5 221t-145.5 406q0 130 51 248.5t136.5 204 204 136.5 248.5 51 248.5-51 204-136.5 136.5-204 51-248.5q0-230-145.5-406t-366.5-221v-228q206 31 374 151t265 305.5 97 398.5z" />
       </svg>
       <svg className="dce-bg-camera"
         viewBox="0 0 2048 1792">
         <path d="M1024 672q119 0 203.5 84.5t84.5 203.5-84.5 203.5-203.5 84.5-203.5-84.5-84.5-203.5 84.5-203.5 203.5-84.5zm704-416q106 0 181 75t75 181v896q0 106-75 181t-181 75h-1408q-106 0-181-75t-75-181v-896q0-106 75-181t181-75h224l51-136q19-49 69.5-84.5t103.5-35.5h512q53 0 103.5 35.5t69.5 84.5l51 136h224zm-704 1152q185 0 316.5-131.5t131.5-316.5-131.5-316.5-316.5-131.5-316.5 131.5-131.5 316.5 131.5 316.5 316.5 131.5z" />
       </svg>
       <div className="dce-video-container"></div>
       <select className="dce-sel-camera"></select>
       <select className="dce-sel-resolution"></select>
       {props.children}
      </div>
    )
    
  3. Initialize Barcode Reader and Camera Enhancer when the component is mounted. Several events are registered.

    React.useEffect(()=>{
      const init = async () => {
        if (BarcodeReader.isWasmLoaded() === false) {
          if (props.license) {
            BarcodeReader.license = props.license;
          }else{
            BarcodeReader.license = "DLS2eyJoYW5kc2hha2VDb2RlIjoiMjAwMDAxLTE2NDk4Mjk3OTI2MzUiLCJvcmdhbml6YXRpb25JRCI6IjIwMDAwMSIsInNlc3Npb25QYXNzd29yZCI6IndTcGR6Vm05WDJrcEQ5YUoifQ=="; //one-day trial license
          }
          BarcodeReader.engineResourcePath = "https://cdn.jsdelivr.net/npm/dynamsoft-javascript-barcode@9.6.31/dist/";
        }
        reader.current = await BarcodeReader.createInstance();
        enhancer.current = await CameraEnhancer.createInstance();
        await enhancer.current.setUIElement(container.current!);
        enhancer.current.on("played", (playCallbackInfo: PlayCallbackInfo) => {
          if (props.onPlayed) {
            props.onPlayed(playCallbackInfo);
          }
        });
        enhancer.current.on("cameraClose", () => {
          if (props.onClosed) {
            props.onClosed();
          }
        });
        enhancer.current.setVideoFit("cover");
        if (props.onInitialized) {
          props.onInitialized(enhancer.current,reader.current);
        }
           
        if (props.scanRegion) {
          enhancer.current.setScanRegion({
            regionLeft:props.scanRegion.left,
            regionTop:props.scanRegion.top,
            regionRight:props.scanRegion.right,
            regionBottom:props.scanRegion.bottom,
            regionMeasuredByPercentage:true
          });
        }
    
        if (props.onInitialized) {
          props.onInitialized(enhancer.current,reader.current);
        }
           
        toggleCamera();
      }
      if (mounted.current === false) {
        init();
      }
      mounted.current = true;
    },[])
    
  4. Monitor the isActive props. Start the camera if it is set to true. Otherwise, stop the camera.

    const toggleCamera = () => {
      if (mounted.current === true) {
        if (props.isActive === true) {
          enhancer.current?.open(true);
        }else{
          stopScanning();
          enhancer.current?.close();
        }
      }
    }
    
    React.useEffect(()=>{
      toggleCamera();
    },[props.isActive])
    
  5. Define functions related to scanning the barcodes. We start an interval to get frames from the camera and read barcodes.

    const interval = React.useRef<any>(null);
    const decoding = React.useRef(false);
    const startScanning = () => {
      stopScanning();
      const decode = async () => {
        if (decoding.current === false && reader.current && enhancer.current) {
          decoding.current = true;
          const results = await reader.current.decode(enhancer.current.getFrame());
          if (props.onScanned) {
            props.onScanned(results);
          }
          decoding.current = false;
        }
      }
      if (props.interval) {
        interval.current = setInterval(decode,props.interval);
      }else{
        interval.current = setInterval(decode,40);
      }
    }
    
    const stopScanning = () => {
      clearInterval(interval.current);
    }
    
  6. Trigger scanning if the camera is opened.

    enhancer.current.on("played", (playCallbackInfo: PlayCallbackInfo) => {
      if (props.onPlayed) {
        props.onPlayed(playCallbackInfo);
      }
      startScanning();
    });
    
  7. Create a camera.css file under styles and import it in root.tsx.

    CSS:

    @keyframes dce-rotate{from{transform:rotate(0turn);}to{transform:rotate(1turn);}}
    .dce-container {position:relative;background-color:white;width:100%;height:100%;}
    .dce-sel-camera {position:absolute;height:20px}
    .dce-sel-resolution {position:absolute;top:20px}
    .dce-bg-loading{animation:1s linear infinite dce-rotate;width:40%;height:40%;position:absolute;margin:auto;left:0;top:0;right:0;bottom:0;fill:#aaa;}
    .dce-bg-camera{display:none;width:40%;height:40%;position:absolute;margin:auto;left:0;top:0;right:0;bottom:0;fill:#aaa;}
    

    root.tsx:

    import styles from "~/styles/main.css";
    
    export const links: LinksFunction = () => [
      { rel: "stylesheet", href: styles },
    ];
    

Use the Barcode Scanner Component in the App

Switch to app/routes/_index.tsx. Let’s use the scanner component in the app.

  1. Import the scanner component and bind an isActive state to it so that we can use a button to control its scanning status.

    export default function Index() {
      const [isActive,setIsActive] = useState(false);
      const startBarcodeScanner = () => {
        if (initialized) {
          setIsActive(true);
        }else{
          alert("Please wait for the initialization and then try again.");
        }
      }
      const stopBarcodeScanner = () => {
        setIsActive(false);
      }
      return (
        <div>
          <h1>Remix Barcode Scanner</h1>
          <button onClick={()=>startBarcodeScanner()} >Start Scanning</button>
          <div className="scanner" style={{display:isActive?"":"none"}}>
            <BarcodeScanner 
              onInitialized={async (_enhancer:CameraEnhancer,reader:BarcodeReader)=>{
                setInitialized(true);
              }} 
              isActive={isActive}
            >
              <button style={{position:"absolute",right:0}} onClick={()=>stopBarcodeScanner()} >Close</button>
            </BarcodeScanner>
          </div>
        </div>
      );
    }
    
  2. Bind the onScanned event. Stop scanning and display the barcode results if barcodes are found.

    JSX:

    const [barcodes, setBarcodes] = useState<TextResult[]>([]);
    //...
    <ol>
      {barcodes.map((barcode,idx)=>(
        <li key={idx}>{barcode.barcodeFormatString+": "+barcode.barcodeText}</li>
      ))}
    </ol>
    <BarcodeScanner
      isActive={isActive}
      onScanned={(results)=> {
        if (results.length>0) {
          setIsActive(false);
          setBarcodes(results);
        }
      }}
    ></BarcodeScanner>
    

Screenshot of the result:

Simple barcode scanner

Read License from Environment Variables

We need to set the license for Dynamsoft Barcode Reader to use it. You can apply for a license here.

We can directly store the license in the code and use it:

BarcodeReader.license = "<license>";

But as Remix is a full-stack framework, we can store it as the system’s environment variable for better flexibility.

  1. Add a loader function in _index.tsx:

    export async function loader() {
      return json({
        ENV: {
          DBR_LICENSE: process.env.DBR_LICENSE,
        },
      });
    }
    

    The function tries to read the license from the environment variables and pass it to the page.

  2. In the page, we can pass the license to the barcode scanner component to use it. If there is no licenses provided, use a one-day public trial.

    export default function Index() {
      const data = useLoaderData<typeof loader>();
      return (
        <BarcodeScanner
          license={data.ENV.DBR_LICENSE ?? "DLS2eyJoYW5kc2hha2VDb2RlIjoiMjAwMDAxLTE2NDk4Mjk3OTI2MzUiLCJvcmdhbml6YXRpb25JRCI6IjIwMDAwMSIsInNlc3Npb25QYXNzd29yZCI6IndTcGR6Vm05WDJrcEQ5YUoifQ=="}
          isActive={isActive}
        ></BarcodeScanner>
      )
    }
    

Scan Books and Store them in PostgreSQL

Next, we can make the barcode scanner more useful by scanning books into a database.

Prisma and PostgreSQL setup

We use Prisma as the ORM.

npm install @prisma/client
npm install prisma --save-dev

After the installation, initialize prisma with the postgresql provider.

npx prisma init --datasource-provider postgresql

You can set up your PostgreSQL server’s URL in .env.

Then, add a book model in the schema.

model Book {
  ISBN      String   @unique
  title     String
  author    String
  createdAt DateTime @default(now())
}

Deploy the model to your database:

npx prisma db push

CRUD

Create a data.ts to create, read, update or delete a record.

import { PrismaClient } from "@prisma/client";

export type BookRecord = {
  ISBN:string;
  title:string;
  author:string;
  createdAt: string;
};

const books = {
  convertRecord(book:{
    ISBN: string;
    title: string;
    author: string;
    createdAt: Date;
  }|null):BookRecord|null{
    if (book) {
      return {
        ISBN:book.ISBN,
        title:book.title,
        author:book.author,
        createdAt:book.createdAt.getTime().toString()
      }
    }else{
      return null;
    }
  },

  async getAll(): Promise<BookRecord[]> {
    const prisma = new PrismaClient();
    const allBooks = await prisma.book.findMany();
    const bookRecords = [];
    for (let index = 0; index < allBooks.length; index++) {
      const converted = this.convertRecord(allBooks[index]);
      if (converted) {
        bookRecords.push(converted);
      }
    }
    await prisma.$disconnect();
    return bookRecords;
  },

  async get(id: string): Promise<BookRecord | null> {
    const prisma = new PrismaClient();
    const book = await prisma.book.findUnique({
      where: {
        ISBN: id,
      },
    })
    await prisma.$disconnect();
    return this.convertRecord(book);
  },

  async create(record: BookRecord): Promise<void> {
    const prisma = new PrismaClient();
    await prisma.book.create({
      data: {
        title: record.title,
        author: record.author,
        ISBN: record.ISBN
      },
    })
    await prisma.$disconnect();  
  },

  async set(id: string, record: BookRecord): Promise<void> {
    const prisma = new PrismaClient();
    await prisma.book.update({
      where: {
        ISBN: id,
      },
      data: {
        author: record.author,
        title: record.title,
      },
    })
    await prisma.$disconnect();
  },

  async destroy(id: string): Promise<void> {
    const prisma = new PrismaClient();
    await prisma.book.delete({
      where: {
        ISBN: id,
      },
    })
    await prisma.$disconnect();
  },
};

export async function addBook(record:BookRecord){
  await books.create(record);
} 

export async function getBooks(): Promise<BookRecord[]>{
  return await books.getAll();
} 

export async function getBook(id:string): Promise<BookRecord|null>{
  return await books.get(id);
} 

export async function editBook(id:string,record:BookRecord): Promise<void>{
  await books.set(id,record);
} 

export async function deleteBook(id:string): Promise<void>{
  await books.destroy(id);
} 

Scan Books to Database

Create a new file named scanner.tsx under app/routes. It can open the barcode scanner, get the book’s info via the ISBN barcodes and save the book into the database.

The action function is used to receive the form data sent from the web page.

Screenshot:

Book scanner

Code:

import { type MetaFunction, type LinksFunction, redirect, ActionFunctionArgs, json } from "@remix-run/node";
import { useEffect, useState } from "react";
import BarcodeScanner from "~/components/BarcodeScanner";
import {CameraEnhancer} from "dynamsoft-camera-enhancer";
import styles from "~/styles/camera.css";
import { BarcodeReader, TextResult } from "dynamsoft-javascript-barcode";
import { Form, useLoaderData, useNavigate, useNavigation, useRouteError } from "@remix-run/react";
import { addBook } from "~/data";
import { queryBook } from "~/bookAPI";

export const meta: MetaFunction = () => {
  return [
    { title: "Remix Barcode Scanner" },
    { name: "description", content: "Remix Barcode Scanner." },
  ];
};

export const links: LinksFunction = () => [
  { rel: "stylesheet", href: styles },
];

export async function loader() {
  return json({
    ENV: {
      DBR_LICENSE: process.env.DBR_LICENSE,
    },
  });
}

export const action = async ({
  params,
  request,
}: ActionFunctionArgs) => {

  const formData = await request.formData();
  const updates = Object.fromEntries(formData);
  const title = updates.title.toString();
  const author = updates.author.toString();
  const ISBN = updates.ISBN.toString();
  const timeStamp = new Date().getTime().toString();
  await addBook({
    title:title,
    author:author,
    ISBN:ISBN,
    createdAt:timeStamp
  })  
  return redirect(`/`);
};

export function ErrorBoundary() {
  const error = useRouteError();
  const navigate = useNavigate();
  useEffect(()=>{
    const goBack = () => {
      navigate("/");
    }
    setTimeout(goBack,3000);
  },[])
  return (
    <div>Failed to create a new record. Maybe the book has been scanned.</div>
  );
}


export default function Scanner() {
  const data = useLoaderData<typeof loader>();
  const navigate = useNavigate();
  const { state } = useNavigation();
  const [isActive,setIsActive] = useState(false);
  const [initialized,setInitialized] = useState(false);
  const [ISBN,setISBN] = useState("");
  const [author,setAuthor] = useState("");
  const [title,setTitle] = useState("");
  const busy = state === "submitting";
  const startBarcodeScanner = () => {
    if (initialized) {
      reset();
      setIsActive(true);
    }else{
      alert("Please wait for the initialization and then try again.");
    }
  }
  const stopBarcodeScanner = () => {
    setIsActive(false);
  }

  const reset = () => {
    setISBN("");
    setAuthor("");
    setTitle("");
  }

  return (
    <div>
      <h1>New Book</h1>
      <Form id="book-form" method="post" 
        onSubmit={(event) => {
          if (!ISBN) {
            alert("ISBN must not be empty");
            event.preventDefault();
          }
        }}>
        <div>
          <label>
            ISBN:
            <input name="ISBN" onChange={e=>setISBN(e.target.value)} type="text" value={ISBN}/>
          </label>
          <button type="button" onClick={()=>startBarcodeScanner()} >Scan</button>
        </div>
        <div>
          <label>
            Title:
            <input name="title" onChange={e=>setTitle(e.target.value)} type="text" value={title}/>
          </label>
        </div>
        <div>
          <label>
            Author:
            <input name="author" onChange={e=>setAuthor(e.target.value)} type="text" value={author}/>
          </label>
        </div>
        <button type="submit" disabled={busy}>{busy ? "Submiting..." : "Submit"}</button>
        <button type="button" onClick={()=>navigate(-1)}>Back</button>
      </Form>
      <div className="scanner" style={{display:isActive?"":"none"}}>
        <BarcodeScanner 
          license={data.ENV.DBR_LICENSE ?? "DLS2eyJoYW5kc2hha2VDb2RlIjoiMjAwMDAxLTE2NDk4Mjk3OTI2MzUiLCJvcmdhbml6YXRpb25JRCI6IjIwMDAwMSIsInNlc3Npb25QYXNzd29yZCI6IndTcGR6Vm05WDJrcEQ5YUoifQ=="}
          onInitialized={async (_enhancer:CameraEnhancer,reader:BarcodeReader)=>{
            const settings = await reader.getRuntimeSettings();
            settings.expectedBarcodesCount = 0;
            await reader.updateRuntimeSettings(settings);
            setInitialized(true);
          }} 
          onScanned={async (results)=> {
            console.log(results);
            if (results.length>0) {
              setIsActive(false);
              console.log("set ISBN");
              setISBN(results[0].barcodeText);
              try {
                let bookInfo = await queryBook(results[0].barcodeText);
                setTitle(bookInfo.title);
                setAuthor(bookInfo.author);
              } catch (error) {
                alert("Faild to fill in title and author automatically.");
              }
            }
          }}
          scanRegion={{
            top:20,
            left:0,
            right:100,
            bottom: 60
          }}
          isActive={isActive}
        >
          <button style={{position:"absolute",right:0}} onClick={()=>stopBarcodeScanner()} >Close</button>
        </BarcodeScanner>
      </div>
    </div>
  );
}

The book info is retrieved using Google’s API:

export interface BookInfo{
  title:string;
  author:string;
}

export async function queryBook(ISBN:string):Promise<BookInfo> {
  try {
    let response = await fetch("https://www.googleapis.com/books/v1/volumes?q=isbn:"+ISBN);
    let json = await response.json();
    let bookItem = json["items"][0];
    let title = bookItem["volumeInfo"]["title"];
    let author = bookItem["volumeInfo"]["authors"].join(", ");
    return {
      title:title,
      author:author
    }
  } catch (error) {
    throw error;
  }
}

Display Books in Index

  1. Create a new BookCard component to display a book.

    import { BookRecord } from "~/data";
    import { ReactElement, ReactNode, useState } from "react";
    
    export interface BookCardProps {
      record:BookRecord,
      children?:ReactNode
    }
    
    const BookCard = (props:BookCardProps): ReactElement => {
      const renderValue = (key:string) => {
        const value = (props.record as any)[key];
        if (key === "createdAt") {
          return new Date(parseInt(value)).toUTCString();
        }else{
          return value;
        }
      }
      return (
        <div className="book-card">
          {Object.keys(props.record).map((key)=>(
            <div className="book-info-item" key={key}>
              <label>
                {key.toUpperCase()}:&nbsp;
              </label>
              <input type="text" defaultValue={renderValue(key)}/>
            </div>
          ))}
          {props.children}
        </div>
      )
    }
    
    export default BookCard;
    

    CSS:

    .book-card {
      padding: 1rem;
      border: 1px solid black;
      border-radius: 10px;
      margin-top: 1rem;
      margin-bottom: 1rem;
    }
    
    .book-card p {
      margin-block-start: 1em;
      margin-block-end: 1em;
    }
    
    .book-info-item {
      display: flex;
    }
    
    .book-card .book-info-item label {
      flex-basis:35%;
    }
    
    .book-card .book-info-item input {
      flex-basis:65%;
    }
    
  2. In index page, get the books and display them.

    export const loader = async () => {
      const books = await getBooks();
      return json({ books });
    };
    
    
    export default function Index() {
      const { books } = useLoaderData<typeof loader>();
      return (
        <div>
          {books.map((bookRecord,idx)=>(
            <BookCard record={bookRecord} key={"book-card-"+idx}>
              <Link to={`/books/`+bookRecord.ISBN}>View</Link>
            </BookCard>
          ))}
        </div>
      );
    }
    

Add Book Route

  1. Create a book route module as app/routes/books.$bookId.tsx. It displays the book info and have two buttons to edit and delete it.

    import { Form, useLoaderData,useNavigate } from "@remix-run/react";
    import { getBook } from "../data";
    import BookCard from "~/components/BookCard";
    import { LoaderFunctionArgs, json } from "@remix-run/node";
    import invariant from "tiny-invariant";
    
    export const loader = async ({
      params,
    }: LoaderFunctionArgs) => {
      invariant(params.bookId, "Missing bookId param");
      const bookRecord = await getBook(params.bookId);
      if (!bookRecord) {
        throw new Response("Not Found", { status: 404 });
      }
      return json({ bookRecord });
    };
    
    export default function Book() {
      const navigate = useNavigate();
      const { bookRecord } = useLoaderData<typeof loader>();
      return (
        <div>
          <BookCard record={bookRecord}>
            <div style=>
              <Form action="edit">
                <button type="submit">Edit</button>
              </Form>
              <Form
                action="destroy"
                method="post"
                onSubmit={(event) => {
                  const response = confirm(
                    "Please confirm you want to delete this record."
                  );
                  if (!response) {
                    event.preventDefault();
                  }
                }}
              >
                <button type="submit">Delete</button>
              </Form>
            </div>
          </BookCard>
          <button onClick={()=>navigate("/")}>Back</button>
        </div>
      );
    }
    
  2. Create a book edit route module as app/routes/books.$bookId_.edit.tsx. Users can edit the book record in this module.

    import { Form, useLoaderData,useNavigate } from "@remix-run/react";
    import { BookRecord, editBook, getBook } from "../data";
    import { ActionFunctionArgs, LoaderFunctionArgs, json, redirect } from "@remix-run/node";
    import invariant from "tiny-invariant";
    
    export const loader = async ({
      params,
    }: LoaderFunctionArgs) => {
      if (params.bookId) {
        const bookRecord = await getBook(params.bookId);
        if (!bookRecord) {
          throw new Response("Not Found", { status: 404 });
        }
        return json({ bookRecord });
      }else{
        throw new Response("Not Found", { status: 404 });
      }
    };
    
    export const action = async ({
      params,
      request,
    }: ActionFunctionArgs) => {
      invariant(params.bookId, "Missing bookId param");
      const formData = await request.formData();
      const updates = Object.fromEntries(formData);
      let bookRecord:BookRecord|null = await getBook(params.bookId);
      if (bookRecord) {
        bookRecord.title = updates.title.toString();
        bookRecord.author = updates.author.toString();
        editBook(params.bookId,bookRecord)
      }
      return redirect(`/books/${params.bookId}`);
    };
    
    export default function EditBook() {
      const navigate = useNavigate();
      const { bookRecord } = useLoaderData<typeof loader>();
      return (
        <Form id="book-form" method="post">
          <p className="book-info-item">
            <label>
              Title:
            </label>
            <input name="title" type="text" defaultValue={bookRecord.title}/>
            <label>
              Author:
            </label>
            <input name="author" type="text" defaultValue={bookRecord.author}/>
          </p>
          <p>
            <button type="submit">Save</button>
            <button type="button" onClick={()=>{
              navigate(-1);
            }}>Cancel</button>
          </p>
        </Form>
      );
    }
    
  3. Create a book destroy route module as app/routes/books.$bookId.destroy.tsx to respond to the delete operation.

    import type { ActionFunctionArgs } from "@remix-run/node";
    import { redirect } from "@remix-run/node";
    import invariant from "tiny-invariant";
    
    import { deleteBook } from "../data";
    
    export const action = async ({
      params,
    }: ActionFunctionArgs) => {
      invariant(params.bookId, "Missing contactId param");
      await deleteBook(params.bookId);
      return redirect("/");
    };
    

All right, we’ve now finished writing the demo. We can deploy it to platforms like Vercel.

Source Code

https://github.com/tony-xlh/remix-barcode-scanner