Chuyện Gì Đang Xảy Ra
Ứng dụng React của bạn khởi động lên, rồi ngay lập tức chết với thông báo này trong console:
Error: Target container is not a DOM element.
React không tìm thấy điểm mount. Nguyên nhân có thể là <div id="root"> chưa bao giờ tồn tại, bị xóa trong lúc bạn chỉnh sửa, hoặc — tinh vi hơn — script của bạn chạy trước khi trình duyệt kịp phân tích xong phần tử đó. Chuyển từ Create React App sang Vite là một nguyên nhân phổ biến, vì hai bundler này kỳ vọng index.html ở các vị trí khác nhau.
Quy Trình Debug
Bước 1: Kiểm tra xem ReactDOM thực sự nhận được gì
Trước khi thay đổi bất cứ thứ gì, hãy log container trong file entry của bạn:
// React 18
const container = document.getElementById('root');
console.log(container); // null = phần tử không tồn tại
ReactDOM.createRoot(container).render(<App />);
// React 16/17
console.log(document.getElementById('root'));
ReactDOM.render(<App />, document.getElementById('root'));
Thấy null trong console? Đó là câu trả lời. Phần tử không có mặt lúc mount — bây giờ bạn chỉ cần tìm hiểu tại sao.
Bước 2: Kiểm tra file index.html của bạn
Mở public/index.html (CRA) hoặc index.html ở thư mục gốc của dự án (Vite). Bạn cần tìm dòng này:
<div id="root"></div>
Ba điều hay làm người ta vấp phải ở đây:
- Div bị xóa trong lúc chỉnh sửa — kiểm tra phần này trước
- Thuộc tính
idbị gõ sai hoặc sai chữ hoa/thường (id="Root"so vớiid="root") - HTML của bạn dùng
id="app"nhưng JS lại gọigetElementById('root')— hoặc ngược lại
Bước 3: Kiểm tra vị trí thẻ script trong HTML tự viết tay
Không dùng bundler để inject script? Script có thể chạy trước khi trình duyệt hoàn tất phân tích trang — khiến getElementById trả về null dù div đang ở đó.
Các Cách Khắc Phục
Fix 1: Thêm div root còn thiếu vào index.html
Chín trong mười trường hợp là vậy. Div biến mất trong lúc chỉnh sửa. Hãy đảm bảo HTML của bạn có container với đúng ID:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>My React App</title>
</head>
<body>
<div id="root"></div>
<!-- Bundler inject script bundle vào đây -->
</body>
</html>
Sau đó xác nhận ID khớp chính xác trong file entry của bạn:
// src/main.jsx (Vite) hoặc src/index.js (CRA)
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const container = document.getElementById('root'); // Phải khớp với id trong HTML
ReactDOM.createRoot(container).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
Fix 2: Chuyển thẻ script xuống sau div root
Script trong <head> chạy trước khi body được phân tích. Khi load script thủ công, JS của bạn kích hoạt trước khi trình duyệt đọc đến dòng <div id="root">.
Sai:
<head>
<script src="bundle.js"></script> <!-- Chạy trước khi #root tồn tại -->
</head>
<body>
<div id="root"></div>
</body>
Đúng:
<body>
<div id="root"></div>
<script src="bundle.js"></script> <!-- Chạy sau khi #root đã có trong DOM -->
</body>
Muốn giữ script trong <head>? Thêm defer. Thuộc tính này báo trình duyệt tải script song song và chỉ thực thi sau khi phân tích xong HTML:
<head>
<script src="bundle.js" defer></script>
</head>
Fix 3: Thêm kiểm tra null lúc mount
Các trang do CMS inject, extension trình duyệt và thiết lập micro-frontend không phải lúc nào cũng đảm bảo phần tử tồn tại. Hãy xử lý trường hợp đó một cách tường minh:
const container = document.getElementById('root');
if (!container) {
throw new Error(
'Root element not found. Make sure index.html contains <div id="root"></div>'
);
}
ReactDOM.createRoot(container).render(<App />);
Một thông báo lỗi tùy chỉnh nêu rõ phần tử còn thiếu luôn tốt hơn thông báo DOM chung chung của React.
Fix 4: Kiểm tra vị trí index.html theo bundler của bạn
Vite và CRA không đồng ý về vị trí của index.html. Điều này làm nhiều người gặp khó khăn khi đang chuyển đổi:
# Cấu trúc dự án Vite
my-vite-app/
├── index.html <-- phải ở đây, với <div id="root">
├── src/
│ └── main.jsx
└── vite.config.js
# Cấu trúc dự án CRA
my-cra-app/
├── public/
│ └── index.html <-- phải ở đây
├── src/
│ └── index.js
└── package.json
Đặt index.html vào sai thư mục và bundler của bạn hoặc không tìm thấy nó, hoặc phục vụ trang trắng không có div root. Cả hai đều không cho bạn thông báo lỗi hữu ích.
Fix 5: Tạo điểm mount theo cách lập trình
Nhúng React vào trang không phải React — một widget, một plugin bên thứ ba — đồng nghĩa phần tử mount có thể chưa tồn tại khi script của bạn chạy. Hãy tự tạo nó thay vì giả định:
// Dễ hỏng: giả sử phần tử đã tồn tại sẵn
const existingEl = document.getElementById('my-widget');
ReactDOM.createRoot(existingEl).render(<Widget />); // Lỗi nếu el là null
// Đáng tin cậy: tạo nó ngay lúc chạy
const mountPoint = document.createElement('div');
mountPoint.id = 'my-widget';
document.body.appendChild(mountPoint);
ReactDOM.createRoot(mountPoint).render(<Widget />);
Xác Nhận Đã Khắc Phục
Khởi động lại dev server. Console sạch không có lỗi đỏ từ React là dấu hiệu đầu tiên cho thấy bạn đã xong. Chạy lệnh này trong DevTools để kiểm tra lại:
// Console của Browser DevTools:
document.getElementById('root'); // Phải trả về phần tử, không phải null
Đã thấy phần tử trở lại? React có thể mount được rồi. Vẫn thấy trang trắng sau bước này? Bạn đang gặp một lỗi JS runtime riêng biệt — vấn đề mount đã được giải quyết, nhưng có thứ gì khác bị hỏng.
Một assertion một dòng giúp bạn phát hiện nhanh hơn trong lúc phát triển:
const container = document.getElementById('root');
console.assert(container !== null, '#root element missing from index.html');
ReactDOM.createRoot(container).render(<App />);
Bài Học Rút Ra
- So sánh chuỗi trong JavaScript phân biệt chữ hoa/thường.
"Root"và"root"không phải cùng một ID — React thất bại thầm lặng với cái này trong khi cái kia hoạt động bình thường. - Các bundler (Vite, CRA, webpack) tự động inject script ở cuối
<body>. HTML viết tay không được hưởng điều đó, nên vị trí script là trách nhiệm của bạn. - Mỗi lần chuyển đổi bundler là một cơ hội để
index.htmllạc vào thư mục sai. 30 giây bỏ ra để xác minh vị trí của nó sau khi chuyển CRA → Vite tiết kiệm được cả tiếng đau đầu. - Một null guard với thông báo mô tả rõ ràng lúc mount là món quà cho chính bạn trong tương lai. "Root element not found" dễ xử lý hơn gấp trăm lần so với "Target container is not a DOM element."

