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
# 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:
@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:
"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:
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:
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:
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:
"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.
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!