Writing a jQuery plugin
Extend $.fn properly, store per-element state, namespace your events, guard against double initialisation, and ship a teardown that actually works.
The plugin skeleton
// A complete, minimal plugin with instance storage and a destroy path.
(function ($) {
'use strict';
const NAME = 'counter';
const DATA_KEY = 'plugin_' + NAME;
const EVENT_KEY = '.' + NAME;
const DEFAULTS = { start: 0, step: 1, max: null, onChange: null };
class Counter {
constructor(element, options) {
this.$el = $(element);
this.options = $.extend({}, DEFAULTS, options); // shallow merge of options
this.value = this.options.start;
this._render();
this.$el.on('click' + EVENT_KEY, '[data-counter-increment]', () => this.increment());
}
increment() {
const next = this.value + this.options.step;
if (this.options.max !== null && next > this.options.max) return;
this.value = next;
this._render();
this.$el.trigger($.Event('change' + EVENT_KEY, { value: this.value }));
if (typeof this.options.onChange === 'function') this.options.onChange(this.value);
}
getValue() { return this.value; }
_render() { this.$el.find('[data-counter-value]').text(this.value); }
destroy() {
// Remove only the events this plugin bound, then drop the instance.
this.$el.off(EVENT_KEY).removeData(DATA_KEY);
this.$el.find('[data-counter-increment]').off(EVENT_KEY);
this.$el = null;
}
static getInstance(element) { return $(element).data(DATA_KEY); }
}
$.fn[NAME] = function (optionsOrMethod, ...args) {
return this.each(function () {
const existing = Counter.getInstance(this);
if (typeof optionsOrMethod === 'string') {
// method call: $('#c').counter('increment')
if (existing && typeof existing[optionsOrMethod] === 'function') {
existing[optionsOrMethod](...args);
}
return;
}
if (!existing) {
$(this).data(DATA_KEY, new Counter(this, optionsOrMethod));
}
});
};
$.fn[NAME].Constructor = Counter;
$.fn[NAME].getInstance = Counter.getInstance;
})(jQuery);| Element | Purpose | Why it matters |
|---|---|---|
| IIFE wrapper | Keeps helpers private, binds $ safely | Survives noConflict() |
'use strict' | Catches accidental globals | Legacy plugins fail here for a reason |
this.each() | Works on a collection, not one element | Callers expect $('.x').plugin() |
DATA_KEY | Per-element instance storage | Enables getInstance and idempotent init |
EVENT_KEY | .off('.plugin') removes only your handlers | Essential inside a component that rebinds |
Return this | Keeps chaining alive | $('#c').counter().addClass('ready') |
$.extend({}, DEFAULTS, options) | Merge without mutating the defaults | Deep merge is available with a leading true |
💡
Never mutate the defaults object.
$.extend(DEFAULTS, options) leaks settings from one instance into every later one — a bug that only appears when a second instance is created, which is exactly the situation a quick test skips.Usage and the double-init guard
// Data attributes as a convenience layer over the plugin
$(function () {
$('[data-counter]').each(function () {
const $this = $(this);
$this.counter($.extend({}, $this.data())); // data-* options as defaults
});
});
// Guarding against double initialisation is the plugin's job, not the caller's
$(document).on('click', '.js-add-counter', function () {
$('#widget').counter({ start: 10 }); // second call is a no-op: instance exists
$('#widget').counter('increment'); // method call on the existing instance
console.log($('#widget').counter('getValue'));
});
// Reading options from data attributes means numbers arrive as numbers and
// JSON arrives parsed, because .data() converts them.
// <div data-counter data-start="5" data-max="10">
// Teardown: give the caller an explicit way out
function teardown(selector) {
$(selector).each(function () {
const instance = $.fn.counter.getInstance(this);
if (instance) instance.destroy();
});
}$.extend({}, $this.data())passes everydata-*key as an option, including ones the plugin does not know about. Harmless with a merge, but do not then iterate options and assume they are real.- The method-call branch should return early for unknown method names rather than throwing, so an unexpected string does not break the chain.
- If the plugin uses a timer, an interval or a global document listener,
destroy()must clear it. Anything bound to the element itself is removed byoff(EVENT_KEY). - Publish the constructor and
getInstanceon$.fn[NAME]so other code can reach the instance without a new$().data()string.
// A second plugin that composes the first: this is where the instance API pays off.
(function ($) {
$.fn.counterGroup = function () {
return this.each(function () {
const $group = $(this);
let total = 0;
$group.find('[data-counter]').each(function () {
const counter = $.fn.counter.getInstance(this);
if (!counter) return; // not initialised yet: skip
total += counter.getValue();
});
$group.find('[data-counter-total]').text(total);
});
};
})(jQuery);What separates a good plugin from a bad one
| Practice | Good | Bad |
|---|---|---|
| Initialisation | Idempotent per element | Rebinding handlers on every call |
| Events | Namespaced with .off('.name') | $(document).off(), killing other code |
| Options | Merged into a fresh object | Writing into the shared defaults |
| State | Stored with .data(DATA_KEY) | A module-level variable shared by all instances |
| DOM access | Scoped to this | $('.item') across the whole page |
| Teardown | destroy() clears timers and events | No way out but a page reload |
| Documentation | Options table, events, methods | "See the source" |
// The checklist as executable tests (see the testing chapter for the runner)
describe('counter plugin', () => {
it('does not double-initialise', () => {
const $el = $('<div data-counter><span data-counter-value></span></div>').appendTo(document.body);
$el.counter({ start: 1 });
$el.counter({ start: 99 }); // ignored
expect($el.counter('getValue')).toBe(1);
});
it('destroy removes handlers', () => {
const $el = $('<div data-counter><button data-counter-increment></button></div>').appendTo(document.body);
$el.counter();
$el.counter('destroy');
expect($el.find('[data-counter-increment]')[0].click());
expect($el.counter('getValue')).toBeUndefined();
});
});The last section is really about ownership: a plugin that stores state on the element and namespaces its events can be instantiated twice, torn down and re-created inside a single-page view without leaving anything behind. That property is what makes a plugin safe to use from modern code.
FAQ
Why does my plugin run twice on each click?
It was initialised twice. Either the initialiser called
$('.x').plugin() after the markup was re-rendered without destroying the previous instance, or the data attribute auto-init runs and the code calls the plugin explicitly. Store the instance and check it before constructing.How do I make a plugin accept a method name instead of options?
Branch on
typeof optionsOrMethod === 'string', look up the method on the existing instance, and call it with any extra arguments. Return early for unknown names so the chain is not broken.Related
Events, effects and attributes Performance, event delegation and memory
Last refreshed 2026-09-18.