Hầu hết các form upload ảnh đều trông như thế này: một ô <input type="file"> xấu xí, không có preview, user không biết mình vừa chọn ảnh gì.
Bài này sẽ xây từng bước một custom ImagePicker component có giao diện riêng và preview ảnh trước khi submit — chỉ với useRef, useState, và useEffect. Không cần thư viện ngoài.
Bước 1 — Bắt đầu với input cơ bản
Tạo component ImagePicker nhận vào title (label hiển thị) và name (dùng cho id và name của input):
Dùng trong form:
Đây là điểm xuất phát. Bây giờ bắt đầu nâng cấp dần.
Bước 2 — Ẩn input, thêm button tùy chỉnh
File input mặc định của trình duyệt rất khó style. Cách phổ biến là ẩn nó đi, rồi dùng một button tùy chỉnh để trigger click vào input ẩn đó thông qua useRef.
Vì dùng useRef và các DOM event, component này phải là Client Component:
useRef ở đây không để lưu state — nó lưu tham chiếu trực tiếp đến DOM node của input ẩn. Khi button được click, ta gọi .click() trên input đó, trình duyệt sẽ mở hộp thoại chọn file như bình thường.
Dùng type="button" cho button để tránh nó vô tình submit form khi click.
Bước 3 — Preview ảnh đã chọn
Khi người dùng chọn file, sự kiện onChange của input được kích hoạt. Từ đó lấy file và tạo URL tạm thời để hiển thị preview bằng URL.createObjectURL():
URL.createObjectURL(file) tạo ra một URL dạng blob:http://... trỏ thẳng đến file trong bộ nhớ — không cần upload lên server, hiển thị ngay lập tức.
Thêm preview vào JSX:
Và bind handler vào input:
Bước 4 — Dọn dẹp bộ nhớ với useEffect
URL.createObjectURL() cấp phát bộ nhớ cho mỗi URL tạo ra. Nếu không giải phóng, những URL cũ bị ghi đè (khi chọn ảnh mới) hoặc component bị unmount sẽ vẫn chiếm bộ nhớ — gọi là memory leak.
Dùng useEffect để cleanup:
Hàm trả về trong useEffect là cleanup function — React tự động gọi nó trước mỗi lần effect chạy lại (tức là mỗi khi previewUrl thay đổi), và khi component unmount. Như vậy URL cũ luôn được giải phóng đúng lúc.
Ghi chú dành cho những người mới về useEffect
return một function trong useEffect là cú pháp đặc biệt của hook này — không phải return giá trị thông thường. Hàm được truyền vào return bên trong useEffect sẽ chỉ được gọi vào đúng 2 thời điểm:
Trước khi effect chạy lại (tức là previewUrl vừa thay đổi, chuẩn bị chạy effect mới) Trình tự thực tế khi người dùng chọn ảnh lần 2:
URL cũ luôn được dọn trước khi URL mới được tạo — đúng thứ tự, không leak.
Nếu muốn viết tường minh hơn thay vì dùng arrow function, cách đúng là:
return cleanup khác với return cleanup() — cái trước trả về function để React gọi sau, cái sau gọi ngay và trả về undefined.
Bước 5 — Nút xóa ảnh đã chọn
Cần reset cả imageInputRef.current.value — nếu không, input vẫn giữ file cũ trong nội bộ dù preview đã bị xóa, dẫn đến submit sẽ vẫn gửi file đó.
Full component
'use client';
import { useRef, useState, useEffect } from "react";
import Image from "next/image";
export default function ImagePicker({
title,
name,
}: {
title: string;
name: string;
}) {
const imageInputRef = useRef<HTMLInputElement>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const handlePickClick = () => {
imageInputRef.current?.click();
};
const handleImageChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files || files.length === 0) return;
setPreviewUrl(URL.createObjectURL(files[0]));
};
const handleReset = () => {
setPreviewUrl(null);
if (imageInputRef.current) imageInputRef.current.value = "";
};
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
return (
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">{title}</label>
<input
id={name}
name={name}
ref={imageInputRef}
type="file"
accept="image/*"
onChange={handleImageChange}
className="hidden"
/>
{previewUrl && (
<div className="relative h-[200px] w-[200px] overflow-hidden rounded border border-gray-300">
<Image src={previewUrl} alt="Preview" fill className="object-contain" />
</div>
)}
<div className="flex gap-2">
<button type="button" onClick={handlePickClick}
className="border border-gray-300 rounded px-4 py-2">
Chọn ảnh
</button>
{previewUrl && (
<button type="button" onClick={handleReset}
className="border border-gray-300 rounded px-4 py-2">
Xóa ảnh
</button>
)}
</div>
</div>
);
}
Tóm tắt
Bài này là ví dụ thực tế cho thấy ba hook phối hợp với nhau:
useRef — tham chiếu đến DOM node để trigger click programmatically useState — lưu URL preview, khi thay đổi sẽ re-render hiển thị ảnh mới useEffect — cleanup blob URL để tránh memory leak Đây cũng là một trong những trường hợp điển hình phải dùng Client Component trong Next.js — vì cần tương tác với DOM và Browser API (URL.createObjectURL).
Vậy là xong — bạn đã có một image picker xịn, có preview, tự tay xây từ đầu. Biết làm từ gốc rễ như vậy, sau này dù dùng component có sẵn từ thư viện nào đó, bạn cũng hiểu bên dưới nó đang làm gì.
Thử thách tiếp theo: nâng cấp component này để preview nhiều ảnh cùng lúc — lúc đó previewUrl sẽ là một mảng, và cleanup cũng cần xử lý tất cả các URL trong mảng đó.