Katriel tsepelevish - Software Engineer at Vimeo
How to build a Scalable image uploader with TypeScript? - Part 2

How to build a Scalable image uploader with TypeScript? - Part 2

April 2, 2024ยท10 min read

Today, we'll delve into implementing a web application for the scalable backend service.

Requirements

  • Node.js: Ensure that Node.js is installed on your system. You can download and install Node.js from the official website: https://nodejs.org/. Node.js is required to run JavaScript applications, including our backend server and worker processes.

Folders structure

public/
src/
    app/
        favicon.ico
        globals.css
        layout.tsx
        page.tsx
    components/
        error-message.tsx
        image-preview.tsx
        image-uploader.tsx
        upload-input.tsx
    hooks/
        use-image-upload.ts
.env
.eslintrc.json
.gitignore
next-env.d.ts
next.config.mjs
package-json.lock
package.json
pnpm-lock.yaml
postcss.config.js
README.md
tailwind.config.ts
tsconfig.json
  • public/ directory contains static files like images, fonts, or any other assets that you want to be publicly accessible from the root of your application

  • src/ director is the main source code directory of your application

  • app/ directory contains files related to the main application setup and configuration

  • favicon.ico is the favicon for your application

  • globals.css global CSS styles that will be applied across your entire application

  • layout.tsx file contains the layout component that wraps around your pages to provide consistent styling or layout

  • page.tsx is the main entry point of your application, where you define the routing and structure of your pages

  • components/ directory contains reusable UI components used throughout your application

  • hooks/ directory contains custom React hooks used in your application

  • .env configuration file for environment variables used in your application

  • .eslintrc.json ESLint configuration file for linting your code

  • .gitignore specifies intentionally untracked files to ignore when using Git

  • next-env.d.ts TypeScript declaration file for Next.js environment

  • next.config.mjs Next.js configuration file

  • package.json main configuration file for your Node.js project, which includes metadata and dependencies

  • postcss.config.js configuration file for PostCSS, a tool for transforming CSS with JavaScript plugins

  • tailwind.config.ts configuration file for Tailwind CSS, a utility-first CSS framework

  • tsconfig.json TypeScript configuration file

Settings Up the Foundation

To start with Next.js, you can use the following command to create a new project:

> npx create-next-app@latest

This command will set up a new Next.js project for you. During the setup process, you'll be prompted to choose a few configuration options to customize your project. Simply follow the prompts and select the following:

What is your project named? image-uploader-ui
Would you like to use TypeScript? Yes
Would you like to use ESLint? Yes
Would you like to use Tailwind CSS? Yes
Would you like to use `src/` directory? Yes
Would you like to use App Router? (recommended) Yes
Would you like to customize the default import alias (@/*)? No
What import alias would you like configured? @/*

Next, let's install the dependencies '@tanstack/react-query' and 'react-icons':

> npm install @tanstack/react-query react-icons

We utilize the @tanstack/react-query library in this context primarily for its polling functionality, which allows us to continuously check the status of an image upload job. By leveraging the useQuery hook provided by @tanstack/react-query, we can efficiently implement polling logic.

Following, we will create a .env file containing our environment variables:

NEXT_PUBLIC_API_URL=http://localhost:8080/api/v1
.gitignore
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# local env files
.env
.env*.local

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts

Implementing the Application

Let's start by removing any unnecessary styles from the global.css file, it should ONLY include the following:

src/app/global.css
@tailwind base;
@tailwind components;
@tailwind utilities;

Next, let's remove any unnecessary HTML code from the app.tsx file and add a QueryClientProvider provider for the @tanstack/react-query, which will be used later in the image uploader component:

src/app/page.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

import { ImageUploader } from "@/components/image-uploader";

const queryClient = new QueryClient();

export default function Home() {
  return (
    <QueryClientProvider client={queryClient}>
      <div className="flex flex-col gap-4 justify-center items-center p-8">
        <ImageUploader />
      </div>
    </QueryClientProvider>
  );
}

This code sets up the main page of the application. It imports the QueryClient and QueryClientProvider components from the @tanstack/react-query library to provide the global state management for data fetching and caching. A new instance of QueryClient is created and passed as a prop to QueryClientProvider. Inside the QueryClientProvider, there is a div with CSS classes for styling purposes, and the ImageUploader component is rendered within it. This ensures that the ImageUploader component and any child components have access to the query client provided by QueryClientProvider, enabling them to interact with the global state managed by React Query.

Next, we'll implement the components associated with the image uploader feature. We'll start by implementing the errorMessage component:

src/components/error-message.tsx
import { BiError } from "react-icons/bi";

interface Props {
  message: string;
}

export const ErrorMessage = ({ message }: Props) => {
  return (
    <div
      className="flex items-center w-80 p-4 text-red-800 bg-red-50 dark:bg-gray-800 dark:text-red-400"
      role="alert"
    >
      <BiError className="flex-shrink-0 w-6 h-6" />
      <div className="ms-3 text-sm font-medium">{message}</div>
    </div>
  );
};

The ErrorMessage component displays an error message with an error icon. It receives a message prop of type string and renders it within a styled div along with the error icon.

Next, we'll implement the ImagePreview component, which will display the uploaded image:

src/components/image-preview.tsx
interface Props {
  imageUrl: string;
}

export const ImagePreview = ({ imageUrl }: Props) => {
  return (
    <div>
      {/* eslint-disable-next-line */}
      <img src={imageUrl} className="w-80 h-40" />
      <a
        href={imageUrl}
        className="w-full bg-blue-500 hover:bg-blue-700 text-white py-2 px-4 mt-2 inline-flex items-center"
      >
        <span className="w-full text-center">View image</span>
      </a>
    </div>
  );
};

The ImagePreview component accepts a prop called imageUrl, which is a string representing the URL of the image to be displayed. It renders an image element with the specified URL and provides a link to view the image in full size when clicked.

Now, let's implement the hook responsible for handling image upload and status polling. This hook will manage the image uploading process and continuously check the status of the upload:

src/hooks/use-image-upload.ts
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";

export enum JobStatus {
  WAITING = "waiting",
  DELAYED = "delayed",
  ACTIVE = "active",
  FAILED = "failed",
  COMPLETED = "completed",
}

const POLLING_INTERVAL = 500;

export const useImageUpload = () => {
  const apiUrl = process.env.NEXT_PUBLIC_API_URL;

  const [jobId, setJobId] = useState<string | null>(null);
  const [status, setStatus] = useState<JobStatus | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [imageUrl, setImageUrl] = useState<string | null>(null);

  const uploadImage = async (file: File) => {
    try {
      setError(null);
      setJobId(null);
      setStatus(JobStatus.WAITING);
      setImageUrl(null);

      const formData = new FormData();
      formData.append("file", file);

      const response = await fetch(`${apiUrl}/upload-image`, {
        method: "POST",
        body: formData,
      });

      const { error, id } = await response.json();

      if (!response.ok) {
        setStatus(JobStatus.FAILED);
        setError(error);
      }

      setJobId(id);
    } catch (error) {
      setError(
        (error as Error).message ?? "Unexpected Internal Error has occurred"
      );
    }
  };

  const keepPolling =
    !!jobId &&
    ![JobStatus.FAILED, JobStatus.COMPLETED].includes(status as JobStatus);

  useQuery({
    queryKey: ["imageStatus", jobId],
    queryFn: async () => {
      try {
        if (!jobId) return;
        setError(null);

        const response = await fetch(`${apiUrl}/image/${jobId}`);
        const { status, imageUrl } = await response.json();
        setStatus(status);
        setImageUrl(imageUrl);
        return { status, imageUrl };
      } catch (error) {
        setError(`Error fetching image status: ${error}`);
      }
    },
    enabled: keepPolling,
    refetchInterval: POLLING_INTERVAL,
  });

  return { uploadImage, status, error, imageUrl };
};

The useImageUpload hook manages the process of uploading an image and continuously checks its status. It utilizes React's state to track the job ID, status, error messages, and image URL. When an image is uploaded, it sends a POST request to the specified API endpoint and updates the state accordingly. Additionally, it uses the useQuery hook from @tanstack/react-query to fetch the status of the upload at regular intervals, enabling real-time updates on the status of the uploaded image.

Next, we'll implement the UploadInput component, which serves as an input field for selecting and uploading image files within the ImageUploader component:

src/components/upload-input.tsx
"use client";
import { useMemo, ChangeEvent } from "react";
import { ImSpinner } from "react-icons/im";
import { MdOutlineImage } from "react-icons/md";
import { BiError } from "react-icons/bi";
import { FaCloud } from "react-icons/fa";

import { JobStatus } from "@/hooks/use-image-upload";

interface Status {
  icon: React.ReactElement;
  title: string;
  description: string;
  condition: boolean;
}

interface Props {
  status: JobStatus | null;
  onChange: (file: File | undefined) => void;
}

export const UploadInput = ({ status, onChange }: Props) => {
  const statuses: Status[] = useMemo(
    () => [
      {
        icon: <FaCloud className="w-8 h-8 mb-2" />,
        title: "Upload file",
        description: "PNG, JPG, SVG, WEBP, and GIF are Allowed.",
        condition: !status,
      },
      {
        icon: <ImSpinner className="w-8 h-8 mb-2 animate-spin" />,
        title: "Uploading...",
        description: `Status: ${status}`,
        condition: [
          JobStatus.WAITING,
          JobStatus.DELAYED,
          JobStatus.ACTIVE,
        ].includes(status as JobStatus),
      },
      {
        icon: <BiError className="w-8 h-8 mb-2 fill-red-500" />,
        title: "Ooops..",
        description: "Something went wrong, please try again.",
        condition: status === JobStatus.FAILED,
      },
      {
        icon: <MdOutlineImage className="w-8 h-8 mb-2" />,
        title: "Awesome!",
        description: "Uploaded succesfully...",
        condition: status === JobStatus.COMPLETED,
      },
    ],
    [status]
  );

  const activeStatus = statuses.find(({ condition }) => condition);

  const isDisabled =
    !!status &&
    ![JobStatus.COMPLETED, JobStatus.FAILED].includes(status as JobStatus);

  return (
    <label
      className={`bg-white text-black text-base w-80 h-52 flex flex-col items-center justify-center ${
        isDisabled ? "cursor-not-allowed" : "cursor-pointer"
      } border-2 border-gray-300 border-dashed mx-auto font-[sans-serif]`}
    >
      {activeStatus?.icon}
      {activeStatus?.title}
      <input
        type="file"
        id="upload"
        className="hidden"
        disabled={isDisabled}
        multiple={false}
        onChange={(event: ChangeEvent<HTMLInputElement>) => {
          event.preventDefault();
          const files = event.target.files;
          const file = files?.[0];
          onChange(file);
        }}
      />
      <p className="text-xs text-gray-400 mt-2">{activeStatus?.description}</p>
    </label>
  );
};

The UploadInput component facilitates the user interface for uploading image files. It dynamically adjusts its appearance and behavior based on the status of the upload process. Different states, such as "Upload file", "Uploading...", "Ooops..", and "Awesome!", are represented with corresponding icons and messages. Additionally, the component is designed to be responsive to user interactions, allowing users to select a file for upload and triggering the onChange function accordingly.

Let's wrap up with the ImageUploader component, where all the magic happens. This component brings together everything we've built so far. By leveraging the useImageUpload hook, it seamlessly handles image uploading tasks. Users can easily select files using the UploadInput, view any potential errors with the ErrorMessage component, and preview their uploaded images with the ImagePreview component. With a clean and intuitive design, users can effortlessly upload images and stay informed about the upload process.

src/app/components/image-uploader.tsx
import { useImageUpload } from "@/hooks/use-image-upload";
import { UploadInput } from "@/components/upload-input";
import { ErrorMessage } from "@/components/error-message";
import { ImagePreview } from "@/components/image-preview";

export const ImageUploader = () => {
  const { uploadImage, status, error, imageUrl } = useImageUpload();

  const handleFileUpload = async (file: File | undefined) => {
    if (!file) return;
    await uploadImage(file);
  };

  return (
    <div className="flex flex-col gap-2 justify-center items-center p-8">
      <UploadInput onChange={handleFileUpload} status={status} />
      {!!error && <ErrorMessage message={error} />}
      {imageUrl && <ImagePreview imageUrl={imageUrl} />}
    </div>
  );
};

Application's Showcase

I've created a GIF showcasing the application's functionality. Let's check it out!

Conclusion

In conclusion, we've successfully implemented various components and hooks to create an image uploader application in React. By leveraging libraries like @tanstack/react-query and React Icons, we've enhanced the functionality and user experience of the application. The useImageUpload hook manages the upload process and status polling efficiently, while components like UploadInput, ErrorMessage, and ImagePreview provide a clean and intuitive user interface.

Overall, this project demonstrates how to effectively manage asynchronous tasks and handle user interactions in a React application.

Hope you enjoyed and took away something useful from this article. The source code used in this article is published on Github.

Thanks for reading!

See All Articles