Writing a jQuery Plugin That Doesn't Fight the Library
Legacy codebases don't just use jQuery plugins - they accumulate half-written ones: functions bolted onto $.fn that break chaining, double-initialize, and leak handlers on removal. The proper pattern is thirty lines, and knowing it makes you the person who can maintain (or mercy-kill) any plugin in the codebase.
(function ($) {
const NAME = 'tagInput';
const defaults = {
maxTags: 10,
delimiter: ',',
onAdd: null,
};
function Plugin(el, options) {
this.$el = $(el);
this.opts = $.extend({}, defaults, options, this.$el.data()); // data-* overrides!
this._bind();
}
Plugin.prototype = {
_bind() {
this.$el.on('keydown.' + NAME, (e) => this._onKey(e));
},
addTag(text) { /* ... */ this.opts.onAdd?.(text); },
destroy() {
this.$el.off('.' + NAME).removeData(NAME);
},
};
$.fn[NAME] = function (optionsOrMethod, ...args) {
return this.each(function () { // 1. chainable, multi-element
let inst = $.data(this, NAME);
if (!inst) {
$.data(this, NAME, inst = new Plugin(this, optionsOrMethod)); // 2. one instance per element
} else if (typeof optionsOrMethod === 'string') {
inst[optionsOrMethod]?.(...args); // 3. method calls: $el.tagInput('addTag', 'css')
}
});
};
})(jQuery);
Why each piece is there
return this.each(...) - chainability and multi-element support in one move. $('.tags').tagInput().addClass('ready') must work; plugins that return something else break the contract users expect from every jQuery method.
$.data guard - calling the plugin twice on an element returns the existing instance instead of double-binding (the bug that makes widgets fire twice after an AJAX re-init - namespaces being the other half of that defense, used in _bind and destroy).
Options merge order - defaults, then the options object, then this.$el.data(). That last one lets markup configure instances - <input data-max-tags="5"> - which is how one init line serves twenty differently-configured elements. (jQuery's .data() auto-reads data-* attributes, camelCased.)
String dispatch - $el.tagInput('destroy') is the jQuery-idiomatic method call. Prefix private methods with _ and skip dispatching them if you want an actual API boundary.
destroy() - unbind namespaced events, removeData. Plugins without teardown are why single-page-ified legacy apps leak; this is four lines.
The honest framing for 2026
Would I start a green-field widget as a jQuery plugin? No - a plain class taking an element covers it. But the pattern still pays rent three ways: maintaining the plugins you have, wrapping a modern component so thousand-line legacy files can consume it idiomatically, and reading third-party plugin source to debug it - which, with this pattern in your head, goes from archaeology to skimming.
Full-stack web developer sharing practical tutorials and building tools that ship.
Got something on your mind?
My inbox is open - no forms disappearing into the void here.
- Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
- Hire me for a project Have something custom in mind? Let's talk scope and timelines.
- Product support Bought something here? I'll help you get it running.
I usually reply within 1-2 business days.