npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2025 – Pkg Stats / Ryan Hefner

react-hook-form-autosave

v3.0.5

Published

Advanced autosave utilities for React Hook Form with debounce, validation, key mapping, diff handling, and comprehensive testing support.

Readme

react-hook-form-autosave

npm version bundle size license

Effortless autosave for React Hook Form with smart field tracking, undo/redo, and perfect synchronization.

Demo

const { isSaving, hasPendingChanges, undo, redo } = useRhfAutosave({
  form,
  transport: (data) => fetch('/api/save', { method: 'POST', body: JSON.stringify(data) })
});

Accurate pending state - Always know if there are unsaved changes
Undo/Redo support - Let users undo mistakes with Cmd/Ctrl+Z
Array field handling - Smart diffing for add/remove operations
Auto-hydration - Seamlessly sync when data loads from API
Production ready - Battle-tested with proper error handling


Table of Contents


📦 Installation

npm install react-hook-form-autosave
# or
pnpm add react-hook-form-autosave
# or  
yarn add react-hook-form-autosave

🚀 Quick Start

Step 1: Add the hook to your form

import { useForm } from "react-hook-form";
import { useRhfAutosave } from "react-hook-form-autosave";

function MyForm() {
  const form = useForm({
    defaultValues: { name: "", email: "" }
  });

  const { isSaving, hasPendingChanges } = useRhfAutosave({
    form,
    transport: async (data) => {
      const response = await fetch("/api/save", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(data),
      });
      return { ok: response.ok };
    },
  });

  return (
    <form>
      <input {...form.register("name")} placeholder="Name" />
      <input {...form.register("email")} placeholder="Email" />
      <div>
        {isSaving ? "💾 Saving..." : hasPendingChanges ? "✏️ Editing..." : "✅ Saved"}
      </div>
    </form>
  );
}

That's it! Your form now autosaves with accurate pending state tracking.


💡 Why Choose This?

The Problem

  • Users lose work when browsers crash or they navigate away
  • Form state gets out of sync with server data
  • Building reliable autosave from scratch is complex
  • Tracking "unsaved changes" accurately is surprisingly hard

The Solution

// Without this library (complex and buggy)
const [saving, setSaving] = useState(false);
const [hasPending, setHasPending] = useState(false);
const lastSavedRef = useRef();

const debouncedSave = useMemo(() => debounce(async (data) => {
  setSaving(true);
  try {
    if (form.formState.isValid) {
      await saveData(data);
      lastSavedRef.current = data;
      setHasPending(false);
    }
  } catch (error) {
    // handle error...
  } finally {
    setSaving(false);
  }
}, 600), []);

useEffect(() => {
  const isDifferent = !deepEqual(form.getValues(), lastSavedRef.current);
  setHasPending(isDifferent);
  if (form.formState.isDirty && isDifferent) {
    debouncedSave(form.getValues());
  }
}, [form.watch()]);

// With this library (simple and reliable)
const { isSaving, hasPendingChanges } = useRhfAutosave({ form, transport: saveData });

🎯 Core Features

1. Accurate Pending State Tracking

The hasPendingChanges boolean accurately tracks whether there are unsaved changes, even after:

  • Data loads from the API
  • Successful saves
  • Undo/redo operations
  • Array field modifications
const { hasPendingChanges } = useRhfAutosave({ form, transport });

// Use it to show save status
{hasPendingChanges ? "You have unsaved changes" : "All changes saved"}

// Or to warn before navigation
useEffect(() => {
  const handleBeforeUnload = (e: BeforeUnloadEvent) => {
    if (hasPendingChanges) {
      e.preventDefault();
      e.returnValue = '';
    }
  };
  window.addEventListener('beforeunload', handleBeforeUnload);
  return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [hasPendingChanges]);

2. Undo/Redo Support

Built-in undo/redo with keyboard shortcuts:

const { undo, redo, canUndo, canRedo } = useRhfAutosave({
  form,
  transport,
  undo: { enabled: true }
});

// Keyboard shortcuts work automatically (Cmd/Ctrl+Z, Shift+Cmd/Ctrl+Z)
// Or add buttons:
<button onClick={undo} disabled={!canUndo}>Undo</button>
<button onClick={redo} disabled={!canRedo}>Redo</button>

3. Auto-Hydration

Automatically syncs when data loads from your API:

function MyForm() {
  const form = useForm();
  const { data } = useQuery(['form-data'], fetchFormData);
  
  const { hasPendingChanges } = useRhfAutosave({
    form,
    transport,
    autoHydrate: true // Enabled by default
  });
  
  // When data loads, the form automatically syncs
  useEffect(() => {
    if (data) {
      form.reset(data); // This triggers auto-hydration
    }
  }, [data]);
  
  // hasPendingChanges will be false after data loads
  // and true only when user makes changes
}

4. Array Field Diffing

Handle array fields intelligently with add/remove operations:

const { isSaving } = useRhfAutosave({
  form,
  transport,
  diffMap: {
    tags: {
      idOf: (tag) => tag.id,
      onAdd: async (tag) => {
        await api.addTag(postId, tag.id);
      },
      onRemove: async (tag) => {
        await api.removeTag(postId, tag.id);
      },
    },
  },
});

// Now when users add/remove tags, only the changes are sent
// The form properly tracks these as saved after success

📖 Basic Examples

Simple Status Display

function MyForm() {
  const form = useForm({ defaultValues: { title: "" } });
  
  const { isSaving, lastError, hasPendingChanges } = useRhfAutosave({
    form,
    transport: async (data) => {
      await api.save(data);
      return { ok: true };
    },
  });

  return (
    <form>
      <input {...form.register("title")} />
      
      {/* Clean status indicator */}
      <div className="status">
        {isSaving && "💾 Saving..."}
        {!isSaving && hasPendingChanges && "✏️ Editing..."}
        {!isSaving && !hasPendingChanges && "✅ All changes saved"}
        {lastError && `❌ Error: ${lastError.message}`}
      </div>
    </form>
  );
}

Configuration Options

const { isSaving } = useRhfAutosave({
  form,
  transport,
  config: {
    debounceMs: 1000,      // Wait 1 second after user stops typing
    debug: true,           // Enable debug logging (default: false)
    enableMetrics: true,   // Track performance metrics
    maxRetries: 3,         // Retry failed saves
    enableCache: true,     // Cache validation results
  }
});

Validation Control

const { isSaving } = useRhfAutosave({
  form,
  transport,
  shouldSave: ({ isDirty, isValid }) => isDirty && isValid,
  validateBeforeSave: "payload", // Only validate changed fields
});

🔧 Common Patterns

Manual Save Button

Sometimes you want both autosave AND a manual save button:

function MyForm() {
  const form = useForm();
  const { isSaving, flush, hasPendingChanges } = useRhfAutosave({
    form,
    transport,
    config: { debounceMs: 2000 } // Auto-save after 2 seconds
  });

  const handleManualSave = async () => {
    const result = await flush(); // Save immediately
    if (result.ok) {
      toast.success("Saved!");
    }
  };

  return (
    <form>
      <input {...form.register("title")} />
      
      <button 
        type="button" 
        onClick={handleManualSave}
        disabled={!hasPendingChanges || isSaving}
      >
        {isSaving ? "Saving..." : "Save Now"}
      </button>
    </form>
  );
}

Form Submission Integration

Ensure all changes are saved before final submission:

function MyForm() {
  const form = useForm();
  const { flush, hasPendingChanges } = useRhfAutosave({
    form,
    transport
  });

  const onSubmit = async (data) => {
    // Save any pending autosave changes first
    if (hasPendingChanges) {
      await flush();
    }
  };

  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      <input {...form.register("title")} />
      <button type="submit">Submit</button>
    </form>
  );
}

Server Data Synchronization

Handle server data updates properly:

function MyForm() {
  const form = useForm();
  const { data, isLoading } = useQuery(['form-data'], fetchData);
  
  const { hasPendingChanges, hydrateFromServer } = useRhfAutosave({
    form,
    transport,
    autoHydrate: true // Auto-detect and sync server data
  });
  
  // Option 1: Auto-hydration (recommended)
  useEffect(() => {
    if (data) {
      form.reset(data); // Auto-hydration handles the rest
    }
  }, [data]);
  
  // Option 2: Manual hydration
  const handleRefresh = async () => {
    const freshData = await fetchData();
    hydrateFromServer(freshData); // Manually sync
  };
  
  return (
    <div>
      {!isLoading && (
        <div>
          {hasPendingChanges 
            ? "You have unsaved changes" 
            : "Synced with server"}
        </div>
      )}
    </div>
  );
}

🚀 Advanced Features

Field Name Mapping

Transform form field names to match your API:

const { isSaving } = useRhfAutosave({
  form,
  transport,
  keyMap: {
    // Simple rename
    fullName: "name",
    
    // Rename + transform value
    age: ["age_years", Number],
    
    // Complex transformation  
    isActive: ["status", (value) => value ? "active" : "inactive"],
  },
});

// Form has: { fullName: "John", age: "25", isActive: true }
// API gets: { name: "John", age_years: 25, status: "active" }

Undo to Last Save

Let users revert all changes since the last save:

const { undoLastSave, hasPendingChanges } = useRhfAutosave({
  form,
  transport,
  undo: { enabled: true }
});

<button onClick={undoLastSave} disabled={!hasPendingChanges}>
  Discard Changes
</button>

Performance Monitoring

const { getMetrics } = useRhfAutosave({
  form,
  transport,
  config: { 
    enableMetrics: true,
    debug: false // Keep debug off in production
  }
});

// Monitor performance
useEffect(() => {
  const interval = setInterval(() => {
    const metrics = getMetrics();
    analytics.track('autosave_metrics', {
      successRate: (metrics.successfulSaves / metrics.totalSaves * 100),
      avgSaveTime: metrics.averageSaveTime,
      totalSaves: metrics.totalSaves
    });
  }, 60000); // Every minute
  return () => clearInterval(interval);
}, []);

📚 API Reference

Hook Signature

const result = useRhfAutosave<FormData>(options)

Options

| Option | Type | Required | Description | |--------|------|----------|-------------| | form | UseFormReturn<T> | ✅ | React Hook Form instance | | transport | (data) => Promise<SaveResult> | ✅ | Function to save data | | config | AutosaveConfig | ❌ | Configuration options | | undo | UndoOptions | ❌ | Undo/redo configuration | | diffMap | Record<string, DiffHandler> | ❌ | Array field handlers | | validateBeforeSave | "none" \| "payload" \| "all" | ❌ | Validation strategy | | shouldSave | (ctx) => boolean | ❌ | Custom save condition | | selectPayload | (values, dirty) => Partial<T> | ❌ | Select fields to save | | onSaved | (result, payload) => void | ❌ | Save callback | | keyMap | KeyMap | ❌ | Field name mapping | | autoHydrate | boolean | ❌ | Auto-sync server data (default: true) |

Configuration

interface AutosaveConfig {
  debug: boolean;          // Debug logging (default: false)
  debounceMs: number;      // Delay before save (default: 600)
  maxRetries: number;      // Retry attempts (default: 3)
  enableMetrics: boolean;  // Track metrics (default: false)
  enableCache: boolean;    // Cache validation (default: true)
  cacheSize: number;       // Cache entries (default: 100)
  cacheTtlMs: number;      // Cache TTL (default: 5 min)
}

Return Value

interface AutosaveReturn {
  // Status
  isSaving: boolean;                    // Currently saving
  lastError: Error | null;              // Last error
  hasPendingChanges: boolean;           // Unsaved changes exist
  
  // Actions
  flush: () => Promise<SaveResult>;     // Save immediately
  abort: () => void;                    // Cancel pending saves
  forceSave: () => Promise<SaveResult>; // Force save
  
  // Undo/Redo
  undo: () => void;                     // Undo last change
  redo: () => void;                     // Redo change
  undoLastSave: () => void;            // Revert to last save
  canUndo: boolean;                     // Can undo
  canRedo: boolean;                     // Can redo
  
  // Advanced
  hydrateFromServer: (data: T) => void; // Manual sync
  getMetrics: () => AutosaveMetrics;    // Performance data
  getPendingChanges: () => any;         // View pending data
}

🔗 Framework Integration

Next.js + tRPC

import { api } from "~/utils/api";
import { trpcTransport } from "react-hook-form-autosave";

function MyForm() {
  const form = useForm();
  const mutation = api.posts.update.useMutation();
  
  const { isSaving, hasPendingChanges } = useRhfAutosave({
    form,
    transport: trpcTransport(mutation),
  });

  return (
    <div>
      {hasPendingChanges ? "Unsaved changes" : "All saved"}
    </div>
  );
}

Remix

import { useFetcher } from "@remix-run/react";

function MyForm() {
  const form = useForm();
  const fetcher = useFetcher();
  
  const { isSaving, hasPendingChanges } = useRhfAutosave({
    form,
    transport: async (data) => {
      fetcher.submit(data, { 
        method: "POST", 
        encType: "application/json" 
      });
      return { ok: true };
    },
  });

  return <>{/* form fields */}</>;
}

📦 Migration Guide

From v2.x to v3.x

1. Debug flag moved to config:

// Before (v2.x)
useRhfAutosave({
  form,
  transport,
  debug: true
});

// After (v3.x)
useRhfAutosave({
  form,
  transport,
  config: { debug: true }
});

2. hasPendingChanges is now accurate:

  • Properly tracks array field changes
  • Correctly syncs after server data loads
  • Works with undo/redo operations

3. Auto-hydration enabled by default:

// Opt-out if needed
useRhfAutosave({
  form,
  transport,
  autoHydrate: false
});

🎯 Best Practices

⚡ Performance

// ✅ Good: Reasonable debounce
config: { debounceMs: 600 }

// ✅ Good: Disable debug in production
config: { debug: false }

// ✅ Good: Only save valid data
shouldSave: ({ isDirty, isValid }) => isDirty && isValid

🔒 Error Handling

const transport = async (data) => {
  try {
    const response = await fetch('/api/save', {
      method: 'POST',
      body: JSON.stringify(data)
    });
    
    if (!response.ok) {
      throw new Error(`Save failed: ${response.status}`);
    }
    
    return { ok: true };
  } catch (error) {
    console.error('Autosave error:', error);
    return { ok: false, error };
  }
};

🧹 Cleanup

// The hook automatically cleans up on unmount
// For special cases:
const { abort } = useRhfAutosave({ form, transport });

useEffect(() => {
  return () => {
    abort(); // Cancel any pending saves
  };
}, [abort]);

🙋‍♀️ FAQ

const { isSaving } = useRhfAutosave({
  form,
  transport,
  shouldSave: ({ isDirty, isValid }) => isDirty && isValid,
  validateBeforeSave: "payload" // Only validate changed fields
});
const { isSaving } = useRhfAutosave({
  form,
  transport,
  selectPayload: (values, dirtyFields) => {
    // Only save name and email fields
    return { name: values.name, email: values.email };
  }
});
const { isSaving } = useRhfAutosave({
  form,
  transport,
  config: {
    debounceMs: 1000,    // Wait longer before saving
    maxRetries: 5,       // More retry attempts
  }
});

This is a client-side hook that requires React Hook Form, so it needs to be used in client components. However, the transport function can call server actions:

'use client';

import { useRhfAutosave } from 'react-hook-form-autosave';
import { saveFormData } from './actions'; // Server action

const { isSaving } = useRhfAutosave({
  form,
  transport: async (data) => {
    try {
      await saveFormData(data);
      return { ok: true };
    } catch (error) {
      return { ok: false, error };
    }
  }
});

📄 License

MIT © Ziad Ziadeh


⭐ Star this repo if it helped you build better forms!