MaterializeCSS Initialization: Auto vs Manual — What Breaks When Content Loads Late
MaterializeCSS v1.x auto-initializes components once on load. Dynamic content added via AJAX or routers stays uninitialized until you re-scan or instantiate manually. Learn how to handle these boundaries and avoid listener leaks.
20 Mar 2026, 15:33 UTC

The Problem: Components Don't Appear After AJAX or Router Updates
You drop a <select> into a page served by a traditional server render. It gets the Materialize styling automatically. Then you fetch a fragment via fetch() and inject it with innerHTML. The new <select> sits there, unstyled, no floating label, no dropdown. The same thing happens with modals, tabs, tooltips — any component that relies on Materialize's JavaScript.
This isn't a bug. It's the contract between auto-initialization (the M.AutoInit() scan that runs once on DOMContentLoaded) and manual initialization (calling new M.Component(el, opts) yourself). Understanding when each applies, and how to bridge the gap for dynamic content, saves hours of "why isn't this working?" debugging.
How Auto-Initialization Works (and When It Doesn't)
Materialize v1.x exposes a global M object (UMD build) or named exports (ES modules). On load, M.AutoInit() walks the document looking for elements with data-* attributes that map to components — for example data-target on a modal trigger, data-toggle="tooltip" on an icon, or a bare <select> that gets the form-select treatment.
Auto-init runs once. It does not use a MutationObserver in the stable v1.x releases. Any markup added after that initial scan — via AJAX, a client-side router, a templating library, or even document.createElement — is invisible to Materialize until you trigger initialization again.
Components that support auto-init include: Modal, Dropdown, Tabs, Select, Tooltip, Collapsible, Sidenav, Materialbox, ScrollSpy, Carousel, and the form input helpers (range, date picker, validation).
Manual Initialization: Control and Instance Access
Calling new M.Modal(document.querySelector('.modal'), { opacity: 0.5 }) returns the component instance. That instance gives you .open(), .close(), .destroy(), and direct access to .el and .options. With auto-init you get none of that unless you later retrieve the instance via M.Modal.getInstance(element).
Manual init is also the only reliable path in SSR/hydration frameworks (Next.js, Nuxt, Astro). The server-rendered HTML arrives without Materialize JS; M.AutoInit() either runs too early (before hydration) or not at all. The standard pattern is to initialize in a client-side effect hook:
// Next.js / React example
useEffect(() => {
const elems = document.querySelectorAll('.modal');
M.Modal.init(elems, { opacity: 0.5 });
return () => elems.forEach(el => M.Modal.getInstance(el)?.destroy());
}, []);
Where to run: Browser console or client-side bundle. Permissions: None beyond script execution. Risk: Calling init twice on the same element without destroying the first instance duplicates event listeners.
Worked Example: Initializing a Modal Added via Fetch
Imagine a dashboard that loads a "Create Project" modal from an endpoint when the user clicks a button. The modal markup includes data-target="create-modal" on the trigger and class="modal" id="create-modal" on the dialog.
- Fetch the fragment:
const html = await fetch('/modals/create-project').then(r => r.text()) - Inject it:
document.getElementById('modal-container').innerHTML = html - Re-scan only the new subtree:
M.AutoInit(document.getElementById('modal-container')) - Open it programmatically:
M.Modal.getInstance(document.getElementById('create-modal')).open()
Step 3 is the key. Passing a root element to M.AutoInit(root) limits the scan to that subtree, avoiding re-initialization of already-active components elsewhere on the page. If you need the instance immediately, skip auto-init and instantiate manually:
const modalEl = document.getElementById('create-modal');
const instance = new M.Modal(modalEl, {
onOpenStart: () => console.log('opening'),
onCloseEnd: () => modalEl.remove() // cleanup
});
instance.open();
Verification: After step 3, open the browser console and run M.Modal.getInstance(document.getElementById('create-modal')). You should see a Modal object with isOpen: false until you call .open().
Trade-offs: Re-initialization Risks and SSR Gotchas
- Duplicate listeners: Calling
M.AutoInit()on a subtree that already contains initialized components attaches a second set of event handlers. Guard with adata-initializedflag or destroy first:M.Component.getInstance(el)?.destroy()before re-init. - No TypeScript definitions in the package: The npm package
materialize-css@1.0.0ships no.d.tsfiles. Community types at@types/materialize-cssmay lag behind the runtime API. Declaredeclare const M: anyor write a minimal ambient module if you need compile-time checks. - SSR hydration mismatch: If you server-render a
<select>with Materialize classes but initialize only on the client, users see a flash of unstyled content. Mitigate by hiding the select untiluseEffectruns.
A Pattern You Can Use Today
Bootstrap once, then treat every dynamic fragment as a mini-bootstrap:
// app.js — runs once on initial load
M.AutoInit();
// utils/initMaterialize.js — call after any DOM injection
export function initMaterialize(root = document) {
// Option A: full auto-scan (simple, but may double-init)
M.AutoInit(root);
// Option B: manual for components you need to control
// root.querySelectorAll('.modal').forEach(el => {
// if (!M.Modal.getInstance(el)) new M.Modal(el);
// });
}
Drop initMaterialize(container) into your router's afterEach, your AJAX success callback, or your component's onMounted. Check the result with M.Component.getInstance(element) in the console. If it returns an instance, the component is live. If it returns undefined, the selector missed or the element lacks the required data-* attribute.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.