Advanced Plugin Patterns
In Using & Writing Plugins you wrote a small plugin with options and a destroy command. Plugins in real projects, such as the ones inside Bootstrap 3 and 4, company UI kits, or the "common.js" file every old admin panel has, use a few more patterns. Once you recognise them, a 600-line plugin file becomes easy to read.
This lesson builds one plugin, collapsible, with the same structure those libraries use: a class that holds the logic, a thin $.fn bridge, method calls with arguments, events that other code can cancel, automatic set-up from data-* attributes, and noConflict. At the end you will see the jQuery UI widget factory and the plain JavaScript version of the same idea.
The structure at a glance
| Piece | Example | Why it exists |
|---|---|---|
| A class with the logic | class Collapsible { show() {...} } | Keeps each element's state and methods together |
The bridge on $.fn | $.fn.collapsible = function (option) {...} | Lets people write $(el).collapsible("show") |
| One instance per element | .data("ec.collapsible") | Calling the plugin twice reuses the same object |
| Public defaults and class | $.fn.collapsible.defaults, .Constructor | Site-wide settings and access to the class |
| Events | show.collapsible, shown.collapsible | Other code can react, or cancel |
| Data API | data-toggle="collapsible" | Works from HTML alone, no JavaScript call needed |
noConflict() | $.fn.collapsible.noConflict() | Restores an older plugin with the same name |
Building it step by step
- Write the class. It receives the element and the merged options, and has
show(),hide(),toggle()anddestroy()methods. It changes thehiddenattribute and keeps every trigger button'saria-expandedin sync, so screen readers know the state. - Fire events before and after. Before showing, trigger a
$.Event("show.collapsible"). If any handler calledevent.preventDefault(), stop. After showing, triggershown.collapsible. - Write the bridge. For each element, find the stored instance or create one. If the first argument is a string, call that method, passing any extra arguments along. Refuse unknown names and names starting with
_(private). - Expose defaults, the class and
noConflict. Save any older$.fn.collapsiblefirst, sonoConflict()can put it back. - Add the data API. One delegated click handler on
documentfinds buttons withdata-toggle="collapsible"and toggles theirdata-target.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Collapsible plugin</title>
<style>
.faq { max-inline-size: 30rem; border-block-end: 1px solid #ddd; padding-block: 0.4rem; }
.faq button { all: unset; cursor: pointer; font-weight: bold; }
.faq button:focus-visible { outline: 2px solid #0769ad; }
#log { font-size: 0.9rem; color: #555; }
</style>
</head>
<body>
<div class="faq">
<button data-toggle="collapsible" data-target="#a1" aria-controls="a1" aria-expanded="false">Can I pay the hostel fee in two parts?</button>
<p id="a1" hidden>Yes. Pay ₹3,500 at admission and the rest before the 10th of next month.</p>
</div>
<div class="faq">
<button data-toggle="collapsible" data-target="#a2" aria-controls="a2" aria-expanded="false">Is there a bus from Pune station?</button>
<p id="a2" hidden>Yes, the college bus leaves the station gate at 8 am on weekdays.</p>
</div>
<div class="faq">
<button data-toggle="collapsible" data-target="#a3" aria-controls="a3" aria-expanded="false">Staff only: fee waiver rules</button>
<p id="a3" hidden>Internal notes (locked: the show event is cancelled below).</p>
</div>
<p><button id="all">Open all</button></p>
<ol id="log"></ol>
<script src="https://cdn.jsdelivr.net/npm/jquery@4.0.0/dist/jquery.min.js"></script>
<script>
(function ($) {
const KEY = "ec.collapsible";
// Step 1: the class
class Collapsible {
constructor(element, options) {
this.$el = $(element);
this.options = $.extend({}, $.fn.collapsible.defaults, options);
this.$triggers = $('[data-target="#' + element.id + '"]');
}
isOpen() { return !this.$el.prop("hidden"); }
show() { this._change(true); }
hide() { this._change(false); }
toggle() { this._change(!this.isOpen()); }
destroy() { this.$el.removeData(KEY); }
// Step 2: events before and after; "show"/"hide" can be cancelled
_change(open) {
if (open === this.isOpen()) return;
const before = $.Event(open ? "show.collapsible" : "hide.collapsible");
this.$el.trigger(before);
if (before.isDefaultPrevented()) return;
this.$el.prop("hidden", !open);
this.$triggers.attr("aria-expanded", String(open));
if (open && this.options.focus) this.$el.attr("tabindex", "-1").trigger("focus");
this.$el.trigger(open ? "shown.collapsible" : "hidden.collapsible");
}
}
// Step 4 (part): remember any older plugin with the same name
const old = $.fn.collapsible;
// Step 3: the bridge
$.fn.collapsible = function (option, ...args) {
return this.each(function () {
let instance = $(this).data(KEY);
if (!instance) {
instance = new Collapsible(this, typeof option === "object" ? option : {});
$(this).data(KEY, instance);
}
if (typeof option === "string") {
if (option.startsWith("_") || typeof instance[option] !== "function") {
throw new Error('collapsible has no method "' + option + '"');
}
instance[option](...args);
}
});
};
// Step 4: defaults, the class and noConflict
$.fn.collapsible.defaults = { focus: false };
$.fn.collapsible.Constructor = Collapsible;
$.fn.collapsible.noConflict = function () {
$.fn.collapsible = old;
return this;
};
// Step 5: the data API, one delegated handler for the whole page
$(document).on("click.collapsible.data-api", '[data-toggle="collapsible"]', function (event) {
event.preventDefault();
$($(this).attr("data-target")).collapsible("toggle");
});
})(jQuery);
// Using it from other code
$("#a3").on("show.collapsible", function (event) {
event.preventDefault(); // cancel: staff only
$("<li>").text("Blocked: staff only").appendTo("#log");
});
$(".faq p").on("shown.collapsible hidden.collapsible", function (event) {
$("<li>").text(this.id + ": " + event.type).appendTo("#log");
});
$("#all").on("click", function () {
$(".faq p").collapsible("show");
});
</script>
</body>
</html>Click the questions, then "Open all". The third answer never opens because a handler cancels its show.collapsible event. Notice that the page never calls .collapsible() to set things up: the data API creates each instance on the first click.
Methods that return a value
The bridge above always returns this.each(...), so it stays chainable. But what about $("#a1").collapsible("isOpen")? A getter must return a value, so it breaks the chain on purpose and answers for the first element, like jQuery's own .val() or .attr():
const getters = ["isOpen"];
$.fn.collapsible = function (option, ...args) {
if (typeof option === "string" && getters.includes(option)) {
const first = this.first().collapsible(); // make sure an instance exists
return first.data("ec.collapsible")[option](...args); // a value, not a jQuery set
}
return this.each(function () { /* same as before */ });
};
$("#a1").collapsible("isOpen"); // true or falseWhen you read an old plugin, look for exactly this split: a list of getter names, and a path that returns this.each(...) for everything else.
Events with namespaces
Plugin events usually use a name plus a namespace, such as show.bs.modal in Bootstrap 3 and 4. The part after the first dot is a jQuery namespace, so $(el).off(".collapsible") removes every handler of this plugin in one go. The "before" event (show) is cancellable with event.preventDefault(); the "after" event (shown) is not, because the change has already happened. Many teams rely on this to block a modal from closing while a form is saving.
The jQuery UI widget factory
jQuery UI ships a helper, $.widget(), that builds this whole structure for you: the bridge, one instance per element, method calls, an option method, events, and destroy. You only write the parts that matter:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Widget factory</title>
</head>
<body>
<p>Hostel fee so far: <button id="fee"></button> (click to add)</p>
<ul id="log"></ul>
<script src="https://cdn.jsdelivr.net/npm/jquery@4.0.0/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery-ui@1.14.2/dist/jquery-ui.min.js"></script>
<script>
$.widget("ec.feeCounter", {
options: { amount: 0, step: 500 },
_create() {
this._show();
this._on({ click: "add" }); // removed automatically on destroy
},
add() {
this.options.amount += this.options.step;
this._show();
this._trigger("change", null, { amount: this.options.amount }); // fires "feecounterchange"
},
_show() { this.element.text("₹" + this.options.amount.toLocaleString("en-IN")); },
_destroy() { this.element.text(""); },
});
$("#fee")
.feeCounter({ amount: 4000, step: 1000 })
.on("feecounterchange", function (event, data) {
$("<li>").text("Changed to " + data.amount).appendTo("#log");
});
$("#fee").feeCounter("add"); // call a method
$("#fee").feeCounter("option", "step", 250); // change an option later
</script>
</body>
</html>If a codebase uses $.widget(...), the method calls, _create, _destroy and _trigger all follow these rules. The next lesson, jQuery UI & DataTables-Style Widgets, shows the widgets jQuery UI provides.
The same idea in plain JavaScript
Without jQuery, the class stays almost the same. Instead of the bridge, give the class a static method that finds or creates the instance, and use CustomEvent with cancelable: true for the "before" event:
class Collapsible {
static #instances = new WeakMap();
static getOrCreate(el) {
if (!this.#instances.has(el)) this.#instances.set(el, new Collapsible(el));
return this.#instances.get(el);
}
constructor(el) { this.el = el; }
toggle() {
const opening = this.el.hidden;
const before = new CustomEvent(opening ? "collapsible:show" : "collapsible:hide", { bubbles: true, cancelable: true });
if (!this.el.dispatchEvent(before)) return; // false means a handler cancelled it
this.el.hidden = !opening;
document.querySelectorAll(`[data-target="#${this.el.id}"]`)
.forEach((b) => b.setAttribute("aria-expanded", String(opening)));
}
}
document.addEventListener("click", (event) => {
const button = event.target.closest('[data-toggle="collapsible"]');
if (button) Collapsible.getOrCreate(document.querySelector(button.dataset.target)).toggle();
});A WeakMap plays the role of .data(): it links each element to its instance without stopping the element from being garbage-collected. For a simple show-and-hide, also consider the native <details> element, which needs no JavaScript at all. Custom Events and Accessible Widgets with JavaScript in the JavaScript course go deeper.
Common mistakes
- Creating a new instance on every call. Always look for the stored instance first, or the state resets.
- Calling any string as a method. Check that the method exists and is not private, or
$(el).plugin("constructor")does strange things. - Returning a value from inside
this.each(). The return is ignored; getters need their own path. - Triggering events without a namespace. Use
show.mypluginso other code can remove only your handlers. - Forgetting accessibility. A collapsible needs
aria-expandedon its button and a real<button>element.
Key points
- Professional plugins put the logic in a class and keep a thin
$.fnbridge that stores one instance per element with.data(). - String arguments call methods; extra arguments are passed along; getters return a value for the first element.
- Fire a cancellable "before" event (
show.x) and an "after" event (shown.x); checkisDefaultPrevented(). - A data API (
data-toggle,data-targetplus one delegated handler) makes plugins work from HTML alone. noConflict()restores an older plugin of the same name;$.widget()builds this structure for you in jQuery UI.
Exercise
Add an accordion option to the collapsible plugin: when a panel with accordion: "#faq-group" opens, every other open panel inside that container must close first. Pass the option through .collapsible({ accordion: "#faq-group" }), and make sure the hide.collapsible events still fire for the panels that close.
Show solution
// In the class: toggle() must go through show() and hide()
toggle() { this.isOpen() ? this.hide() : this.show(); }
show() {
if (this.options.accordion) {
$(this.options.accordion).find("[id]").not(this.$el).each(function () {
const other = $(this).data("ec.collapsible");
if (other && other.isOpen()) other.hide(); // fires hide/hidden events
});
}
this._change(true);
}
// Set up every panel in the group with the option before the first click
$("#faq-group > .faq > p").collapsible({ accordion: "#faq-group" });toggle() now calls show(), so clicks from the data API also close the other panels. Each open sibling is closed through its own hide() method, so its hide.collapsible and hidden.collapsible events fire as usual and can still be cancelled.
Quick Quiz
1. Why does the bridge store the instance with .data()?
Without a stored instance, every call would create a new object with fresh state.
2. How can another script stop a panel from opening?
The plugin checks isDefaultPrevented() on the "before" event and stops if it was cancelled.
3. Why does $("#a1").collapsible("isOpen") need a special path in the bridge?
Getters break the chain on purpose and answer for the first element.
4. What does the widget factory call when you write $("#fee").feeCounter("option", "step", 250)?
$.widget() provides an option method for every widget.
Learn jQuery with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
