New releases

Hotwire Club tooling is now open-source

Explore the agentic skills pack and the MCP server for building assistant workflows.

Turbo Streams - Custom Stream Actions - pushState

Synchronize browser history with Turbo Stream responses using a custom push_state action, a Stimulus controller, and the popstate event.

Turbo Streams - Custom Stream Actions - pushState

Premise

In Challenge 19 — building on Challenge 8 (inline stream tags) and Challenge 9 (custom actions) — we stored ephemeral state in localStorage. That worked, but the browser’s Back and Forward buttons don’t know about it. Hit Back after switching versions three times? You leave the page entirely.

history.pushState() fixes this — but the part most people overlook is the first argument: the state object. It stashes data directly in the history entry. When the user hits Back, popstate hands that object back in event.state. No URL parsing, no guessing — your Stimulus controller reads the state and knows exactly what to restore. The URL is for the user and for refresh; the state object is for your JavaScript. One human-facing, the other machine-facing.

Today we’re building a custom push_state stream action (the server pushes URL and state) and a Stimulus controller that reads it back on popstate. TurboPower ships this out of the box — here we’re building it from scratch.

Starting Point

We have a documentation page with a version selector — a <select> dropdown that lets you switch between v1.0, v2.0, and v3.0 of an API. The selector lives inside a Stimulus controller (version-selector), and the whole thing is wrapped in a form with data-turbo-stream set to true — which tells Turbo to send an Accept: text/vnd.turbo-stream.html header, so the server knows to respond with Turbo Stream actions instead of a full HTML page.

The server is a small Express app standing in for a Rails backend. The GET /versions/:version route does what a Rails controller with respond_to { |f| f.turbo_stream } would do: it checks for switch or restore params, sets Content-Type: text/vnd.turbo-stream.html, and returns a string of <turbo-stream> tags. If you make changes to index.js, restart with npm start.

The server is ready to respond with Turbo Streams that replace the content area and the selector itself — but nothing works yet. That’s your job.

The server already handles a ?version= query parameter on full page load — that part is done. What’s missing is the pushState/popstate wiring.

Challenge

Two files need work — app.js for the custom stream action, and the version_selector_controller.js for the Stimulus side:

  1. Register a push_state custom stream action in app.js. The server already includes a <turbo-stream action="push_state"> tag in its response, with url and state attributes. Your action should read both and call history.pushState(state, "", url). The state attribute is JSON — it carries the restore URL that the Stimulus controller will need later on popstate. The url attribute is what the user sees in the address bar.

  2. Implement navigate in the Stimulus controller. When the user picks a version from the dropdown, update the form’s action to the selected value and submit it. The form already has data-turbo-stream, so this triggers a Turbo Stream response. Disable the select while the request is in flight to prevent double-clicks.

  3. Implement restore in the Stimulus controller. This method is wired to popstate@window in the controller’s action descriptor. When the user hits Back or Forward, the browser hands you the state object you stashed earlier — it’s sitting right there in event.state.restoreUrl. Use get() from @rails/request.js with responseKind: "turbo-stream" to fetch that URL. Notice: the restore URL already has restore=1 baked in, which tells the server to skip the push_state stream tag — otherwise you’d push a new history entry on every Back press, creating an infinite loop.

Here’s the end result — note the URL changing with each version switch, and Back/Forward restoring the correct version:

Version selector switching content, URL updating, browser Back and Forward restoring previous versions, and refresh loading the correct version

Teaser

Now that you’ve built push_state from scratch, try dropping in TurboPower and replacing your hand-rolled action with theirs. What would it take to combine this pattern with Turbo’s morphing and page refreshes?

Caveat

A common stumbling point: popstate only fires on actual Back/Forward navigation, never when you call pushState(). The forward direction (user picks a version) is handled by the stream action; the backward direction (user hits Back) is handled by the Stimulus controller’s restore method. Two different mechanisms, one unified UX.

This is The Hotwire Club

46 hands-on challenges with detailed solutions, published biweekly since 2023. Subscribe to access all solutions and join the Discord community.

Subscribe on Patreon

More from

Turbo Streams - List Animations Using the View Transitions API
10 June 2025

Create list animations using Turbo Streams and the View Transitions API

Hotwire Combobox with Real Time Data
12 March 2024

Update combobox options using Websockets and Stimulus outlets

Turbo Streams - Custom Stream Actions - LocalStorage
30 January 2024

Store ephemeral state changes locally using localStorage and custom Turbo Stream Actions.

essential