Identifikasi Masalah
Kami sedang mengembangkan aplikasi reservasi menggunakan Next.js dan MUI sebagai frontend. Pada awalnya, semuanya terlihat normal. Namun, ketika saya bergabung dan mulai mengerjakan migrasi ratusan ribu data booking, tim menemukan masalah performa pada tabel yang menggunakan @mui/x-data-grid-pro versi ^7.29.9.
Tabel tersebut digunakan untuk menampilkan daftar booking. Masalahnya muncul ketika user melakukan row selection menggunakan checkbox. Setiap kali checkbox diklik, terdapat delay sebelum perubahan terlihat, seperti yang ditampilkan pada Figure X di atas.
Saat itu source code masih dikelola oleh pihak vendor dan project masih dalam tahap development. Karena itu, kami hanya bisa mengajukan ticket terkait masalah tersebut.
Sayangnya, jawaban yang kami dapatkan adalah bahwa masalah tersebut memang berkaitan dengan MUI DataGrid. Tidak ada solusi yang diberikan selain rekomendasi untuk mengurangi jumlah row yang ditampilkan dalam satu halaman. Karena tidak ada solusi lebih lanjut, ticket tersebut akhirnya ditutup.
Agak mengecewakan, tetapi kami juga bisa memahami situasinya. Beliau merupakan solo hard carry di project ini, jadi daripada issue tersebut membuat proses development stuck di sana, kami sepakat untuk menutup ticket tersebut.
Solusi sementara yang diberikan adalah membatasi jumlah row yang ditampilkan dalam satu halaman. Rekomendasinya adalah maksimal sekitar 20 row per page.
Pada saat itu, kami menerima solusi tersebut karena project masih berjalan dan belum ada banyak waktu untuk melakukan investigasi lebih dalam.
Hingga beberapa bulan kemudian, setelah project delivery dan source code mulai di-maintain oleh tim internal, saya kembali membuka issue lama tersebut.
Saya mulai mencari apakah ada developer lain yang mengalami masalah yang sama. Dari repository MUI X, saya menemukan beberapa issue dengan gejala yang sangat mirip:
Sayangnya, dari issue-issue tersebut saya belum menemukan solusi yang benar-benar bisa diterapkan pada kasus kami.
Saya memulai dengan menganalisa melalui React Profiler dan melihat apa yang sebenarnya terjadi ketika satu checkbox diklik. Dari profiling tersebut, saya menemukan sesuatu yang cukup menarik. Setiap kali satu checkbox diklik, MuiDataGridVirtualScrollerRenderZone ikut melakukan re-render, dan semua GridRow yang sedang terlihat di layar ikut ter-render ulang. Padahal, hanya satu row yang state selection-nya berubah.
Menurut saya, ini jelas tidak ideal. Kalau hanya satu row yang berubah, seharusnya hanya row tersebut yang perlu melakukan re-render. Dari sinilah saya mulai masuk lebih jauh ke source code @mui/x-data-grid untuk mencari tahu kenapa hal tersebut bisa terjadi.
Menemukan Root Cause
Setelah menelusuri source code MUI DataGrid, saya menemukan bahwa GridRow menerima prop selected: boolean. Saya kemudian menemukan bagian yang menarik di useGridVirtualScroller:
// hooks/features/virtualization/useGridVirtualScroller.js
const selectedRowsLookup = useGridSelector(apiRef, selectedIdsLookupSelector);
Di sini mulai terlihat masalahnya, useGridVirtualScroller melakukan subscription terhadap seluruh object selection lookup melalui selectedIdsLookupSelector.
Ketika satu row di-select atau di-unselect, isi selection lookup berubah. Selector kemudian menghasilkan object baru karena memang ada perubahan pada state selection. Akibatnya, useGridVirtualScroller mendeteksi perubahan tersebut dan melakukan re-render.
Masalahnya adalah useGridVirtualScroller bertanggung jawab terhadap proses rendering row yang terlihat di virtualized render zone. Ketika hook ini berjalan kembali, proses pembuatan JSX untuk row-row tersebut juga dijalankan kembali.
Secara sederhana, alurnya kurang lebih seperti ini:
Click checkbox
↓
rowSelection berubah
↓
selectedIdsLookup berubah
↓
useGridVirtualScroller ter-trigger
↓
Render zone melakukan render ulang
↓
Semua visible GridRow diproses kembali
Padahal perubahan sebenarnya hanya terjadi pada satu row.
Memastikan Bahwa Ini Memang Masalah Versi MUI
Saya menyadari bahwa saya bukan satu-satunya orang yang mengalami masalah ini. Beberapa issue di repository MUI X juga menunjukkan gejala yang serupa.
Kemudian saya menemukan dokumentasi MUI X mengenai DataGrid Performance. Di sana terdapat section yang cukup menarik, yaitu Visualizing the re-rendering process.
Saya mencoba demo tersebut untuk melihat bagaimana DataGrid melakukan re-render. Namun, hasilnya tidak menunjukkan masalah yang sama dengan yang saya alami. Saya kemudian melakukan eksperimen dengan melakukan fork terhadap source code demo tersebut, kemudian mengganti versi DataGrid yang digunakan dengan versi yang sama dengan yang digunakan di project kami.
Setelah itu saya membandingkan hasil rendering-nya.
Dan ternyata dugaan saya benar. Dengan versi DataGrid yang kami gunakan, toggle satu checkbox dapat menyebabkan row-row lain yang terlihat ikut ter-render ulang. Sementara pada versi yang lebih baru, perilakunya sudah jauh lebih baik.
Dari eksperimen tersebut, saya cukup yakin bahwa masalah yang kami alami memang merupakan masalah pada versi MUI DataGrid yang sedang kami gunakan, bukan semata-mata karena jumlah data yang besar atau spesifikasi device user.
Solusi Sementara
Masalah berikutnya adalah: apakah kami harus melakukan upgrade MUI?
Secara teori, upgrade adalah solusi yang paling masuk akal. Namun, melakukan upgrade versi MUI X pada project yang sudah berjalan cukup besar membutuhkan effort dan testing yang tidak sedikit. Kami juga harus memastikan tidak ada perubahan behavior atau breaking changes pada komponen DataGrid yang sudah digunakan di banyak bagian aplikasi.
Karena itu, saya mencoba mencari workaround yang lebih sederhana. Saya mulai dari pertanyaan yang lebih mendasar: Checkbox ini sebenarnya digunakan untuk apa? Checkbox tersebut hanya digunakan untuk menyimpan Ticket ID mana saja yang sedang dipilih, untuk kemudian diproses pada action berikutnya, misalnya:
- mencetak boarding pass;
- melakukan update data secara massal;
- atau menjalankan action terhadap beberapa booking sekaligus.
Tidak ada kebutuhan lain yang mengharuskan selection tersebut menggunakan state internal DataGrid. Jadi, menurut saya, tidak ada alasan nilai sederhana seperti ini harus selalu melewati apiRef.current.state.rowSelection dan kemudian menyebabkan selectedIdsLookupSelector berubah, yang pada akhirnya membuat useGridVirtualScroller ikut bereaksi setiap kali checkbox berubah.
Daripada terus mencoba mengakali subscription bawaan DataGrid, kami memutuskan untuk memisahkan state selection dari DataGrid. Selection state dipindahkan ke external store sederhana. Store ini tidak menggunakan React state dan tidak bergantung pada apiRef. Untuk membuat subscription tetap granular, kami menggunakan useSyncExternalStore, sehingga setiap row hanya subscribe ke state selection miliknya sendiri.
Berikut implementasinya:
export function createRowSelectionStore(): RowSelectionStore {
const selected = new Set<string>();
const rowListeners = new Map<string, Set<() => void>>();
const allListeners = new Set<() => void>();
let version = 0;
const notifyRow = (id: string) => {
rowListeners.get(id)?.forEach((listener) => listener());
};
const notifyAll = () => {
version += 1;
allListeners.forEach((listener) => listener());
};
return {
isSelected: (id) => selected.has(id),
setSelected: (id, value) => {
const had = selected.has(id);
if (value === had) return;
value ? selected.add(id) : selected.delete(id);
notifyRow(id);
notifyAll();
},
// ...setMany, clear, getSelectedIds
subscribeRow: (id, listener) => {
let set = rowListeners.get(id);
if (!set) {
rowListeners.set(id, (set = new Set()));
}
set.add(listener);
return () => set!.delete(listener);
},
subscribeAll: (listener) => {
allListeners.add(listener);
return () => allListeners.delete(listener);
},
getVersion: () => version,
};
}
Kemudian checkbox pada setiap row membaca state selection menggunakan hook khusus:
function useRowSelected(store: RowSelectionStore, id: string) {
return useSyncExternalStore(
useCallback(
(listener) => store.subscribeRow(id, listener),
[store, id]
),
() => store.isSelected(id),
() => false, // getServerSnapshot - wajib ada untuk SSR
);
}
const RowCheckboxCell = React.memo(function RowCheckboxCell({
store,
id,
}) {
const checked = useRowSelected(store, id);
return (
<Checkbox
checked={checked}
onClick={(e) => e.stopPropagation()}
onChange={(e) => store.setSelected(id, e.target.checked)}
/>
);
});
Sekarang, ketika satu row di-toggle, prosesnya menjadi jauh lebih sederhana:
Click checkbox
↓
store.setSelected(id)
↓
notifyRow(id)
↓
RowCheckboxCell untuk row tersebut
↓
Re-render hanya row tersebut
Yang paling penting, proses ini tidak menyentuh apiRef.current.state.rowSelection.
Artinya, selectedIdsLookupSelector milik DataGrid tidak berubah.
Karena selector tersebut tidak berubah, useGridVirtualScroller juga tidak memiliki alasan untuk bereaksi terhadap perubahan checkbox tersebut.
Hasil akhirnya, ketika satu checkbox di-toggle:
RowCheckboxCellpada row tersebut melakukan re-render;- row lain tidak perlu ikut melakukan re-render;
MuiDataGridVirtualScrollerRenderZonetidak ter-trigger hanya karena perubahan checkbox;- state selection tetap bisa digunakan untuk melakukan bulk action.
Untuk header checkbox, kami tetap membutuhkan informasi apakah ada row yang terpilih atau tidak. Untuk kebutuhan tersebut, header dapat melakukan subscription terhadap getVersion() dari store.
Dengan pendekatan ini, state selection menjadi tanggung jawab aplikasi, bukan lagi tanggung jawab DataGrid.
Dan karena kebutuhan kami terhadap checkbox sebenarnya sangat sederhana, pendekatan ini terasa lebih masuk akal dibanding memaksa selection state melewati mekanisme internal DataGrid.
Kesimpulan
Masalah utamanya adalah bagaimana perubahan selection state menyebabkan subscription pada useGridVirtualScroller ikut ter-trigger, yang kemudian membuat proses rendering row pada render zone berjalan kembali.
Hal yang menarik dari kasus ini adalah jumlah data yang besar memang dapat memperparah efeknya, tetapi bukan berarti jumlah data adalah root cause satu-satunya. Dengan pagination yang lebih besar, semakin banyak row yang visible di DOM, semakin besar pula pekerjaan yang harus dilakukan ketika render zone ter-trigger.
Itulah kenapa pada laptop dengan spesifikasi tinggi pun delay masih bisa dirasakan ketika jumlah row per page dinaikkan. Workaround yang kami gunakan bukanlah modifikasi internal MUI DataGrid, melainkan memisahkan kebutuhan aplikasi kami dari selection mechanism bawaan DataGrid.
Ketika menemukan masalah performa pada React component yang kompleks seperti DataGrid, melihat apa yang sebenarnya melakukan re-render sering kali jauh lebih berguna daripada hanya menebak berdasarkan jumlah data.
Kalau ada yang menggunakan MUI DataGrid dan mengalami masalah serupa, semoga tulisan ini bisa membantu sebagai referensi untuk memulai investigasi dari React Profiler sebelum langsung menyimpulkan bahwa masalahnya hanya karena jumlah data.




