Skip to content

Stimulus Targets — Complete Guide with Examples

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you'll learn about Stimulus Targets. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Stimulus targets provide named DOM element references within a controller's scope, replacing document.querySelector with declarative, self-documenting properties.

What You'll Learn

  • Declaring targets with static targets = []
  • Accessing targets: this.nameTarget, this.nameTargets, this.hasNameTarget
  • Using plural targets for collections
  • Target change callbacks with nameTargetsChanged
  • Scoped queries and target naming conventions
  • Practical patterns for form inputs, lists, and dynamic content

Why It Matters

Manual DOM queries with document.querySelector are fragile, unscoped, and hard to maintain. Targets give you self-documenting, scoped element references that make your controllers readable and maintainable. In the Doda Browser extension, targets power form inputs, settings panels, and dynamic list rendering with clear, predictable element access.

Learning Path

flowchart LR
  A[Controllers] --> B[Targets]
  B --> C[Actions]
  C --> D[Values]
  B --> E[Lifecycle &
State] style B fill:#4f46e5,color:#fff,stroke:#4f46e5,stroke-width:2px style E fill:#059669,color:#fff

Declaring Targets

Targets are declared as a static array of strings on your controller class.

app.register('form', class extends Controller {
  static targets = ['name', 'email', 'submit'];

  connect() {
    console.log(this.nameTarget);   // <input name="name">
    console.log(this.emailTarget);  // <input name="email">
    console.log(this.submitTarget); // <button type="submit">
  }
});
<div data-controller="form">
  <input type="text" data-form-target="name">
  <input type="email" data-form-target="email">
  <button data-form-target="submit">Submit</button>
</div>

Teacher explains: For each target name in the static array, Stimulus generates three properties:

  • this.nameTarget — the first matching element (or throws if missing)
  • this.nameTargets — an array of all matching elements
  • this.hasNameTarget — boolean, true if at least one element exists

Target Properties in Detail

this.nameTarget — The First Match

app.register('list', class extends Controller {
  static targets = ['item'];

  highlightFirst() {
    // Throws if no item target exists
    this.itemTarget.classList.add('highlighted');
  }

  safeHighlight() {
    // Guard with hasNameTarget to avoid errors
    if (this.hasItemTarget) {
      this.itemTarget.classList.add('highlighted');
    }
  }
});

this.nameTargets — All Matching Elements

app.register('list', class extends Controller {
  static targets = ['item'];

  markAllComplete() {
    this.itemTargets.forEach(item => {
      item.classList.add('completed');
    });
    console.log(`Marked ${this.itemTargets.length} items complete`);
  }
});
<div data-controller="list">
  <div data-list-target="item">Task 1</div>
  <div data-list-target="item">Task 2</div>
  <div data-list-target="item">Task 3</div>
  <button data-action="click->list#markAllComplete">Complete All</button>
</div>

this.hasNameTarget — Existence Check

app.register('search', class extends Controller {
  static targets = ['input', 'results'];

  search() {
    if (!this.hasInputTarget) {
      console.warn('Search input not found');
      return;
    }

    const query = this.inputTarget.value;
    if (this.hasResultsTarget) {
      this.resultsTarget.innerHTML = this.filterResults(query);
    }
  }

  filterResults(query) {
    // ... filtering logic
  }
});

Target Naming Conventions

HTML uses kebab-case, JavaScript uses camelCase:

HTML Attribute JavaScript Property
data-form-target="error-message" this.errorMessageTarget
data-form-target="submit-btn" this.submitBtnTarget
data-form-target="user-list" this.userListTarget
app.register('form', class extends Controller {
  static targets = ['errorMessage', 'submitBtn', 'userList'];

  showError(message) {
    if (this.hasErrorMessageTarget) {
      this.errorMessageTarget.textContent = message;
      this.errorMessageTarget.classList.remove('hidden');
    }
  }
});
<div data-controller="form">
  <p data-form-target="error-message" class="hidden"></p>
  <button data-form-target="submit-btn">Submit</button>
  <ul data-form-target="user-list"></ul>
</div>

Target Change Callbacks

When the set of targets changes (elements added or removed from the DOM), Stimulus calls a {name}TargetsChanged callback.

app.register('tag-input', class extends Controller {
  static targets = ['tag'];

  tagTargetsChanged() {
    console.log(`Tags changed: now have ${this.tagTargets.length} tags`);
    this.updateCounter();
  }

  addTag(event) {
    const tag = document.createElement('span');
    tag.className = 'tag';
    tag.dataset.tagInputTarget = 'tag';
    tag.textContent = event.detail.name;
    this.element.appendChild(tag);
  }

  updateCounter() {
    console.log(`Total tags: ${this.tagTargets.length}`);
  }
});

Teacher explains: The callback fires whenever elements with the target attribute are added or removed from the controller scope. This is useful for updating UI counters, enabling/disabling buttons, or triggering re-renders when dynamic content changes.

Scoped Queries

Targets are scoped to the controller's element. They never accidentally grab elements from outside.

<div data-controller="parent">
  <div data-controller="child">
    <span data-parent-target="name">Parent</span>
    <span data-child-target="name">Child</span>
  </div>
</div>
app.register('parent', class extends Controller {
  static targets = ['name'];

  log() {
    console.log(this.nameTarget.textContent); // "Parent" -- not "Child"
  }
});

app.register('child', class extends Controller {
  static targets = ['name'];

  log() {
    console.log(this.nameTarget.textContent); // "Child"
  }
});

Teacher explains: Even though the parent and child controllers both have a target named name, they access different elements because each controller is scoped to its own element.

Dynamic Targets

You can add targets dynamically after the controller is connected.

app.register('list', class extends Controller {
  static targets = ['item'];

  addItem(text) {
    const item = document.createElement('div');
    item.dataset.listTarget = 'item';
    item.textContent = text;
    item.classList.add('list-item');
    this.element.appendChild(item);

    // The new element is now automatically a target
    console.log(`Now have ${this.itemTargets.length} items`);
  }

  removeLastItem() {
    const items = this.itemTargets;
    if (items.length > 0) {
      items[items.length - 1].remove();
    }
  }
});

Real-world: Form with Dynamic Inputs

<div data-controller="dynamic-form">
  <div data-dynamic-form-target="container">
    <div class="input-group">
      <input type="text" data-dynamic-form-target="field" placeholder="Value">
      <button data-action="click->dynamic-form#removeField">X</button>
    </div>
  </div>
  <button data-action="click->dynamic-form#addField">Add Field</button>
  <p>Total fields: <span data-dynamic-form-target="counter">1</span></p>
</div>
app.register('dynamic-form', class extends Controller {
  static targets = ['field', 'container', 'counter'];

  fieldTargetsChanged() {
    this.counterTarget.textContent = this.fieldTargets.length;
  }

  addField() {
    const group = document.createElement('div');
    group.className = 'input-group';
    group.innerHTML = `
      <input type="text" data-dynamic-form-target="field" placeholder="Value">
      <button data-action="click->dynamic-form#removeField">X</button>
    `;
    this.containerTarget.appendChild(group);
  }

  removeField(event) {
    event.target.closest('.input-group').remove();
  }
});

Common Mistakes

1. Misspelling Target Names

// HTML: data-form-target="email"
// ❌ JavaScript typo
static targets = ['emaiL']; // Wrong!
this.emailTarget // Error: undefined

2. Not Declaring a Target Before Accessing It

// ❌ Accessing undeclared target
this.someTarget // undefined, no auto-generation
// ✅ Declare it first
static targets = ['some'];
this.someTarget // works

3. Assuming this.nameTarget Returns null for Missing Targets

// ❌ It throws, not returns null
if (this.nameTarget) { } // Error if no target!
// ✅ Use hasNameTarget check
if (this.hasNameTarget) { this.nameTarget }

4. Forgetting Kebab-to-Camel Conversion

// HTML: data-form-target="user-name"
// ❌ Wrong JavaScript name
this.user-nameTarget // Syntax error!
// ✅ Correct
this.userNameTarget

5. Accessing Out-of-Scope Elements

// Targets are scoped to the controller's element
// They cannot access elements outside data-controller parent
// Use outlets for cross-controller access

Practice Questions

1. What three properties does Stimulus generate for each declared target?

this.nameTarget (first element), this.nameTargets (array of all elements), this.hasNameTarget (boolean existence check).

2. What is the naming convention for targets in HTML and JavaScript?

HTML uses kebab-case: data-controller-target="user-name". JavaScript uses camelCase: this.userNameTarget.

3. What happens if you access this.nameTarget when no matching element exists?

Stimulus throws an error. Use this.hasNameTarget to check existence first.

4. When does the {name}TargetsChanged callback fire?

When elements with the target attribute are added to or removed from the controller's scope.

Challenge

Build a tag-manager controller with an input for adding tags, a container that displays tags as removable badges, and a counter showing the total count. Use targets for the input, container, and counter.

FAQ

### Can I use the same target name in nested controllers?

Yes. Targets are scoped to their controller's element. A parent and child controller can both have a target named name without conflict.

What is the difference between a target and querySelector?

Targets are declarative (you can see all DOM dependencies by looking at static targets), scoped (won't accidentally match outside the controller), and self-documenting (the name describes the element's role).

Can I access targets before connect()?

No. Targets are resolved during connect(). In initialize(), target accessors are not yet available.

How do I handle optional targets?

Use this.hasNameTarget to check existence before accessing this.nameTarget. Alternatively, use conditional rendering in your HTML with the target attribute present but the element potentially empty.

What's Next

Topic Description
{{< ref "stimulus-actions" >}} Master action descriptors, event options, keyboard events, and global events
{{< ref "stimulus-values" >}} Configure controllers from HTML with typed values and change callbacks
{{< ref "stimulus-classes" >}} Dynamic CSS class mapping through HTML configuration
JavaScript DOM Review DOM querySelector, scoping, and element traversal

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro. This targets tutorial powers the form inputs and dynamic lists in the Doda Browser extension.

What's Next

Congratulations on completing this Stimulus Targets tutorial! Here's where to go from here:

  • Practice daily — Consistency is more important than long study sessions
  • Build a project — Apply what you learned by building something real
  • Explore related topics — Check out other tutorials in the same category
  • Join the community — Discuss with other learners and share your progress

Remember: every expert was once a beginner. Keep coding!

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro