# Form Auto Save

> Automatic form submission after user input changes using a debounce mechanism to prevent excessive server requests. Creates a seamless auto-save experience for forms with rich text editors or multiple fields.

- Skill: `rolemodel/form-auto-save` (Agent Skill)
- Install (CLI): `npx skillmds@latest add rolemodel/form-auto-save`
- Raw SKILL.md: https://api.skillmd.com/api/skills/rolemodel/form-auto-save/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: rolemodel (https://skillmd.com/u/rolemodel)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/rolemodel/form-auto-save

---


# Form Auto Save Skill

## Overview
The Form Auto Save pattern provides automatic form submission after user input changes, using a debounce mechanism to prevent excessive server requests. This creates a seamless "auto-save" experience for users editing forms.

## When to Use
- Long-form editing interfaces where users expect automatic saving
- Forms with rich text editors or multiple fields
- Edit pages where users might navigate away and expect changes to persist
- Forms that benefit from progressive saving without explicit "Save" button clicks

## Implementation

### 1. Stimulus Controller
The pattern uses a Stimulus controller (`form-auto-save`) that handles the auto-save logic.

**Controller Location:** `app/javascript/controllers/form_auto_save_controller.js`

**Key Features:**
- Debounce time of 8 seconds (configurable via `static DEBOUNCE_TIME`)
- Listens to both `change` and `lexxy:change` events (for custom components)
- Uses passive event listeners for better performance
- Provides `cancel()` and `submit()` methods for programmatic control

**Controller Code Pattern:**
```javascript
import { Controller } from '@hotwired/stimulus'

export default class extends Controller {
  static DEBOUNCE_TIME = 8000

  connect() {
    this.element.addEventListener('change', this.#debounceSubmit.bind(this), { passive: true })
    this.element.addEventListener('lexxy:change', this.#debounceSubmit.bind(this), { passive: true })
  }

  cancel() {
    clearTimeout(this.debounceTimer)
  }

  submit() {
    this.element.requestSubmit()
  }

  #debounceSubmit() {
    this.#debounce(this.submit.bind(this))
  }

  #debounce(callback) {
    clearTimeout(this.debounceTimer)
    this.debounceTimer = setTimeout(callback, this.constructor.DEBOUNCE_TIME)
  }
}
```

### 2. View Integration
Attach the controller to the form element using Stimulus data attributes.

**Required Attributes:**
- `data: { controller: 'form-auto-save' }` - Attaches the Stimulus controller
- `data: { turbo_permanent: true }` - Optional but recommended to preserve form state during Turbo navigation

**Example (Slim):**
```slim
= simple_form_for resource, html: { data: { controller: 'form-auto-save', turbo_permanent: true } } do |f|
  = f.input :field_name
  = f.rich_text_area :content
```

## Important Considerations

### Debounce Time
- Default: 8 seconds (8000ms)
- Adjust via `static DEBOUNCE_TIME` in the controller if needed
- Consider user experience: too short = excessive requests, too long = lost changes

### Event Listeners
- Listens to `change` events (standard HTML input changes)
- Listens to `lexxy:change` events (custom component events, like rich text editors)
- Uses passive listeners for better scroll performance

### Turbo Permanent
- `turbo_permanent: true` keeps the form element across Turbo navigation
- Prevents loss of unsaved changes when user navigates
- Critical for forms with auto-save to maintain debounce timers

### Form Validation
- Ensure backend validation handles partial saves gracefully
- Consider whether all fields should be required or allow partial completion
- Provide clear error feedback if auto-save fails

## Testing

For testing auto-save functionality, use the `turbo-fetch` controller alongside `form-auto-save` to track request completion without relying on sleep timers.

### Turbo Fetch Controller
Add this controller to your JavaScript controllers:

**File:** `app/javascript/controllers/turbo_fetch_controller.js`
```javascript
import { Controller } from '@hotwired/stimulus'
import { patch } from '@rails/request.js'

export default class extends Controller {
  static values = {
    url: String,
    count: Number,
    isRunning: { type: Boolean, default: false }
  }

  async perform({ params: { url: urlParam, query: queryParams } }) {
    this.isRunningValue = true
    const body = new FormData(this.element)

    if (queryParams) Object.keys(queryParams).forEach(key => body.append(key, queryParams[key]))

    const response = await patch(urlParam || this.urlValue, { body, responseKind: 'turbo-stream' })
    this.isRunningValue = false
    if (response.ok) this.countValue += 1
  }
}
```

### Turbo Fetch Helper
Add this helper to your RSpec support files:

**File:** `spec/support/helpers/turbo_fetch_helper.rb`
```ruby
module TurboFetchHelper
  def expect_turbo_fetch_request
    count_value = find("[data-controller='turbo-fetch']")['data-turbo-fetch-count-value'] || 0
    yield
    expect(page).to have_selector("[data-turbo-fetch-count-value='#{count_value.to_i + 1}']")
  end
end
```

### View Integration for Testing
Add the `turbo-fetch` controller alongside `form-auto-save`:

```slim
= simple_form_for resource, html: { data: { controller: 'form-auto-save turbo-fetch', turbo_permanent: true } } do |f|
  = f.input :field_name
  = f.rich_text_area :content
```

### System Spec Example
```ruby
require 'rails_helper'

RSpec.describe 'Form Auto Save', :js do
  it 'automatically saves form after changes' do
    resource = create(:resource)
    visit edit_resource_path(resource)

    expect_turbo_fetch_request do
      fill_in 'Field name', with: 'Updated value'
    end

    expect(resource.reload.field_name).to eq('Updated value')
  end

  it 'debounces multiple rapid changes' do
    resource = create(:resource)
    visit edit_resource_path(resource)

    expect_turbo_fetch_request do
      fill_in 'Field name', with: 'First'
      fill_in 'Field name', with: 'Second'
      fill_in 'Field name', with: 'Final'
    end

    # Should only save once with final value
    expect(resource.reload.field_name).to eq('Final')
  end
end
```

## Common Issues

### Issue: Form doesn't auto-save
**Check:**
- Controller properly attached: `data: { controller: 'form-auto-save' }`
- Form fields trigger `change` events (text inputs may need blur)
- Network requests in browser DevTools

### Issue: Too many requests
**Solutions:**
- Increase `DEBOUNCE_TIME`
- Check for unnecessary event triggers
- Verify debounce logic is working

### Issue: Lost changes on navigation
**Solutions:**
- Add `turbo_permanent: true` to form
- Ensure form has stable `id` attribute
- Consider adding "unsaved changes" warning

## Related Patterns
- **Turbo Streams:** For more complex form updates and partial page replacements
- **Stimulus Values:** If you need per-instance debounce times
- **Form Validation:** Consider inline validation with auto-save

## References
- Stimulus Controller API: https://stimulus.hotwired.dev/
- Turbo Permanent: https://turbo.hotwired.dev/handbook/building#persisting-elements-across-page-loads

