Skip to content

Managing State

Most contemporary frameworks encourage you to keep state in JavaScript at all times. They treat the DOM as a write-only rendering target, reconciled by client-side templates consuming JSON from the server.

Stimulus takes a different approach. A Stimulus application’s state lives as attributes in the DOM; controllers themselves are largely stateless. This approach makes it possible to work with HTML from anywhere—the initial document, an Ajax request, a Turbo visit, or even another JavaScript library—and have associated controllers spring to life automatically without any explicit initialization step.

Building a Slideshow

In the last chapter, we learned how a Stimulus controller can maintain simple state in the document by adding a class name to an element. But what do we do when we need to store a value, not just a simple flag?

We’ll investigate this question by building a slideshow controller which keeps its currently selected slide index in an attribute.

As usual, we’ll begin with HTML:

<div data-controller="slideshow">
<button data-action="slideshow#previous"></button>
<button data-action="slideshow#next"></button>

<div data-slideshow-target="slide">🐵</div>
<div data-slideshow-target="slide">🙈</div>
<div data-slideshow-target="slide">🙉</div>
<div data-slideshow-target="slide">🙊</div>
</div>

Each slide target represents a single slide in the slideshow. Our controller will be responsible for making sure only one slide is visible at a time.

Let’s draft our controller. Create a new file, src/controllers/slideshow_controller.js, as follows:

// src/controllers/slideshow_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
static targets = [ "slide" ]

initialize() {
this.index = 0
this.showCurrentSlide()
}

next() {
this.index++
this.showCurrentSlide()
}

previous() {
this.index--
this.showCurrentSlide()
}

showCurrentSlide() {
this.slideTargets.forEach((element, index) => {
element.hidden = index !== this.index
})
}
}

Our controller defines a method, showCurrentSlide(), which loops over each slide target, toggling the hidden attribute if its index matches.

We initialize the controller by showing the first slide, and the next() and previous() action methods advance and rewind the current slide.

Lifecycle Callbacks Explained

What does the initialize() method do? How is it different from the connect() method we’ve used before?

These are Stimulus lifecycle callback methods, and they’re useful for setting up or tearing down associated state when your controller enters or leaves the document.

Method Invoked by Stimulus…
initialize() Once, when the controller is first instantiated
connect() Anytime the controller is connected to the DOM
disconnect() Anytime the controller is disconnected from the DOM

Reload the page and confirm that the Next button advances to the next slide.

Reading Initial State from the DOM

Notice how our controller tracks its state—the currently selected slide—in the this.index property.

Now say we’d like to start one of our slideshows with the second slide visible instead of the first. How can we encode the start index in our markup?

One way might be to load the initial index with an HTML data attribute. For example, we could add a data-index attribute to the controller’s element:

<div data-controller="slideshow" data-index="1">

Then, in our initialize() method, we could read that attribute, convert it to an integer, and pass it to showCurrentSlide():

  initialize() {
this.index = Number(this.element.dataset.index)
this.showCurrentSlide()
}

This might get the job done, but it’s clunky, requires us to make a decision about what to name the attribute, and doesn’t help us if we want to access the index again or increment it and persist the result in the DOM.

Using Values

Stimulus controllers support typed value properties which automatically map to data attributes. When we add a value definition to the top of our controller class:

  static values = { index: Number }

Stimulus will create a this.indexValue controller property associated with a data-slideshow-index-value attribute, and handle the numeric conversion for us.

Let’s see that in action. Add the associated data attribute to our HTML:

<div data-controller="slideshow" data-slideshow-index-value="1">

Then add a static values definition to the controller and change the initialize() method to log this.indexValue:

export default class extends Controller {
static values = { index: Number }

initialize() {
console.log(this.indexValue)
console.log(typeof this.indexValue)
}

// …
}

Reload the page and verify that the console shows 1 and Number.

What’s with that static values line?

Similar to targets, you define values in a Stimulus controller by describing them in a static object property called values. In this case, we’ve defined a single numeric value called index. You can read more about value definitions in the reference documentation.

Now let’s update initialize() and the other methods in the controller to use this.indexValue instead of this.index. Here’s how the controller should look when we’re done:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
static targets = [ "slide" ]
static values = { index: Number }

initialize() {
this.showCurrentSlide()
}

next() {
this.indexValue++
this.showCurrentSlide()
}

previous() {
this.indexValue--
this.showCurrentSlide()
}

showCurrentSlide() {
this.slideTargets.forEach((element, index) => {
element.hidden = index !== this.indexValue
})
}
}

Reload the page and use the web inspector to confirm the controller element’s data-slideshow-index-value attribute changes as you move from one slide to the next.

Change Callbacks

Our revised controller improves on the original version, but the repeated calls to this.showCurrentSlide() stand out. We have to manually update the state of the document when the controller initializes and after every place where we update this.indexValue.

We can define a Stimulus value change callback to clean up the repetition and specify how the controller should respond whenever the index value changes.

First, remove the initialize() method and define a new method, indexValueChanged(). Then remove the calls to this.showCurrentSlide() from next() and previous():

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
static targets = [ "slide" ]
static values = { index: Number }

next() {
this.indexValue++
}

previous() {
this.indexValue--
}

indexValueChanged() {
this.showCurrentSlide()
}

showCurrentSlide() {
this.slideTargets.forEach((element, index) => {
element.hidden = index !== this.indexValue
})
}
}

Reload the page and confirm the slideshow behavior is the same.

Stimulus calls the indexValueChanged() method at initialization and in response to any change to the data-slideshow-index-value attribute. You can even fiddle with the attribute in the web inspector and the controller will change slides in response. Go ahead—try it out!

Setting Defaults

It’s also possible to set a default values as part of the static definition. This is done like so:

  static values = { index: { type: Number, default: 2 } }

That would start the index at 2, if no data-slideshow-index-value attribute was defined on the controller element. If you had other values, you can mix and match what needs a default and what doesn’t:

  static values = { index: Number, effect: { type: String, default: "kenburns" } }

Wrap-Up and Next Steps

In this chapter we’ve seen how to use the values to load and persist the current index of a slideshow controller.

From a usability perspective, our controller is incomplete. The Previous button appears to do nothing when you are looking at the first slide. Internally, indexValue decrements from 0 to -1. Could we make the value wrap around to the last slide index instead? (There’s a similar problem with the Next button.)

Next we’ll look at how to keep track of external resources, such as timers and HTTP requests, in Stimulus controllers.

Next: Working With External Resources