<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 componentImagePicker nhận vào title (label hiển thị) và name (dùng cho id và name của input):
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 quauseRef.
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ệnonChange 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:
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:
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à
previewUrlvừa thay đổi, chuẩn bị chạy effect mới) - Khi component unmount
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
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
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 programmaticallyuseState— lưu URL preview, khi thay đổi sẽ re-render hiển thị ảnh mớiuseEffect— cleanupblob URLđể tránh memory leak
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 đó.