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);
ElementPurposeWhy it matters
IIFE wrapperKeeps helpers private, binds $ safelySurvives noConflict()
'use strict'Catches accidental globalsLegacy plugins fail here for a reason
this.each()Works on a collection, not one elementCallers expect $('.x').plugin()
DATA_KEYPer-element instance storageEnables getInstance and idempotent init
EVENT_KEY.off('.plugin') removes only your handlersEssential inside a component that rebinds
Return thisKeeps chaining alive$('#c').counter().addClass('ready')
$.extend({}, DEFAULTS, options)Merge without mutating the defaultsDeep 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 every data-* 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 by off(EVENT_KEY).
  • Publish the constructor and getInstance on $.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

PracticeGoodBad
InitialisationIdempotent per elementRebinding handlers on every call
EventsNamespaced with .off('.name')$(document).off(), killing other code
OptionsMerged into a fresh objectWriting into the shared defaults
StateStored with .data(DATA_KEY)A module-level variable shared by all instances
DOM accessScoped to this$('.item') across the whole page
Teardowndestroy() clears timers and eventsNo way out but a page reload
DocumentationOptions 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.

Events, effects and attributes Performance, event delegation and memory

Last refreshed 2026-09-18.