EN
$ cat ~/articles/react-form-autosave.md ← volver a artículos

Un usuario pasa diez minutos rellenando un formulario de contacto detallado. Escribe su problema con precisión, adjunta contexto, revisa todo. Entonces recibe una notificación de Slack, hace clic, y sin querer cierra la pestaña del formulario. Vuelve, abre de nuevo la página, y el formulario está completamente vacío. El usuario no va a escribir todo otra vez. Cierra la página y nunca vuelve.

Este escenario ocurre constantemente. Formularios de checkout donde el usuario pierde la dirección de envío. Formularios de registro con veinte campos que hay que rellenar desde cero. Encuestas largas donde el progreso desaparece. Editores de texto donde el borrador se esfuma.

La solución obvia es persistir el estado del formulario en localStorage. Cada vez que el usuario escribe algo, guardas el estado. Cuando vuelve, restauras. Suena simple hasta que empiezas a implementarlo.

El problema de las soluciones existentes

react-hook-form-persist es la opción más conocida, pero está acoplada a react-hook-form. Si usas Formik, una solución custom, o simplemente useState, no puedes usarla. El paquete no se actualiza desde 2021 y tiene bugs conocidos donde los valores por defecto sobrescriben los datos restaurados después de un refresh.

use-local-storage-state proporciona un reemplazo de useState con sincronización a localStorage. Funciona bien para estado genérico, pero le faltan features específicos de formularios. No tiene exclusión de campos para datos sensibles como contraseñas o números de tarjeta. No tiene debounce, así que cada keystroke dispara una escritura a localStorage. No tiene expiración de datos, ni undo/redo, ni sincronización entre pestañas.

redux-persist y las soluciones de persistencia de Zustand requieren adoptar una librería de gestión de estado global. Para un formulario de contacto o una página de checkout, añadir Redux solo para persistir campos es complejidad innecesaria. Además, el ecosistema de Redux añade ~10KB al bundle.

La alternativa que muchos desarrolladores eligen es escribir un hook custom. Cada tutorial muestra un enfoque diferente, la mayoría con bugs sutiles. Faltan checks de SSR que causan errores de hidratación. No hay debounce, así que localStorage se machaca con cada keystroke. No hay manejo de errores de cuota de almacenamiento. No hay consideración para excluir campos sensibles.

Por qué creé esta librería

Necesitaba persistencia de formularios para varios proyectos con requisitos diferentes. Uno usaba react-hook-form, otro usaba Formik, otro usaba inputs controlados simples. No quería tres soluciones diferentes ni acoplarme a ninguna librería de formularios específica.

Los requisitos eran claros: debía funcionar con cualquier enfoque de formularios, tener zero dependencias para minimizar bundle size, incluir debounce configurable, permitir excluir campos sensibles, soportar expiración de datos, funcionar con SSR sin errores de hidratación, y ser fácil de testear.

Añadí features que había implementado manualmente en proyectos anteriores: undo/redo para que los usuarios puedan deshacer cambios, sincronización entre pestañas para formularios abiertos en múltiples tabs, migraciones de esquema para cuando la estructura del formulario cambia entre versiones, y cumplimiento GDPR con la opción de desactivar persistencia hasta obtener consentimiento.

El resultado es react-form-autosave: una librería que reemplaza useState con persistencia automática. El core está por debajo de 2KB gzipped. Features opcionales como history y sync son imports separados que solo aumentan el bundle si los usas.

Instalación y uso básico

npm install react-form-autosave

El caso más simple es reemplazar useState con useFormPersist. El hook acepta una clave única para identificar el formulario en storage, seguido del estado inicial:

import { useFormPersist } from 'react-form-autosave';

function ContactForm() {
  const [formData, setFormData, { clear }] = useFormPersist('contact-form', {
    name: '',
    email: '',
    message: '',
  });

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    await submitToServer(formData);
    clear(); // Eliminar datos persistidos después de envío exitoso
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" value={formData.name} onChange={handleChange} />
      <input name="email" value={formData.email} onChange={handleChange} />
      <textarea name="message" value={formData.message} onChange={handleChange} />
      <button type="submit">Enviar</button>
    </form>
  );
}

Con este setup mínimo, los datos del formulario se guardan automáticamente en localStorage 500 milisegundos después de que el usuario deja de escribir. Cuando el usuario vuelve a la página, su input anterior se restaura automáticamente.

La API es idéntica a useState. El primer elemento es el estado actual, el segundo es el setter que acepta valor o función actualizadora. El tercer elemento es un objeto con acciones adicionales como clear, undo, redo, y propiedades como isPersisted y lastSaved.

Cómo funciona la persistencia

Cada cambio de estado dispara una operación de guardado con debounce. El debounce por defecto es 500ms, configurable. Esto significa que si el usuario escribe "hola", no se hacen 4 escrituras a localStorage (una por letra), sino una sola escritura 500ms después de la última letra.

Los datos se guardan con metadata: timestamp de última modificación y versión del esquema. El timestamp permite implementar expiración. La versión permite migraciones cuando la estructura del formulario cambia.

Las claves en localStorage tienen prefijo rfp: por defecto para evitar colisiones. Un formulario con clave contact-form se guarda como rfp:contact-form.

En entornos de server-side rendering, la librería detecta que está en el servidor y salta todas las operaciones de storage. La hidratación ocurre correctamente en el cliente sin errores.

Configuración de opciones

Storage y timing

// Usar sessionStorage en lugar de localStorage
useFormPersist('form', initialState, { storage: 'sessionStorage' });

// Cambiar el delay de debounce
useFormPersist('form', initialState, { debounce: 1000 }); // 1 segundo

// Expiración automática
useFormPersist('form', initialState, { expiration: 60 }); // Expira en 1 hora

El storage puede ser localStorage (default), sessionStorage, memory (para testing), o un adaptador custom que implemente getItem, setItem, y removeItem.

Exclusión de campos sensibles

Campos como contraseñas, números de tarjeta, o CVV nunca deben persistirse en localStorage. La opción exclude acepta un array de nombres de campos que se eliminan antes de guardar:

useFormPersist('checkout', initialState, {
  exclude: ['cardNumber', 'cvv', 'password'],
});

Los campos excluidos permanecen en el estado del componente pero no se guardan en storage. Cuando el usuario vuelve, estos campos estarán vacíos mientras el resto se restaura.

Validación antes de guardar

useFormPersist('form', initialState, {
  validate: (data) => data.email.includes('@'),
});

Si la función validate retorna false, el guardado se salta. Útil para evitar persistir datos incompletos o inválidos.

Transformación de datos

useFormPersist('form', initialState, {
  beforePersist: (data) => ({
    ...data,
    lastModified: Date.now(),
  }),
});

La función beforePersist transforma los datos antes de guardarlos. Los datos transformados son lo que se persiste, mientras los datos originales permanecen en el estado del componente.

Versionado y migraciones de esquema

Cuando cambias la estructura de tu formulario entre versiones de tu aplicación, los datos guardados pueden ser incompatibles. La opción version asigna un número de versión a tu esquema de datos. La opción migrate convierte datos de versiones antiguas a la versión actual:

useFormPersist('user-profile', initialState, {
  version: 2,
  migrate: (oldData, oldVersion) => {
    if (oldVersion === 1) {
      // Version 1 tenía firstName y lastName separados
      // Version 2 los combina en fullName
      return {
        ...oldData,
        fullName: `${oldData.firstName} ${oldData.lastName}`,
      };
    }
    return oldData;
  },
});

Cuando el hook monta y encuentra datos con versión antigua, ejecuta la función de migración antes de restaurar. Esto permite evolucionar la estructura de formularios sin perder datos de usuarios.

Undo y redo

Habilitar historial permite a los usuarios navegar por sus cambios:

function Editor() {
  const [content, setContent, actions] = useFormPersist('editor', { text: '' }, {
    history: { enabled: true, maxHistory: 100 },
  });

  return (
    <div>
      <div>
        <button onClick={actions.undo} disabled={!actions.canUndo}>
          Deshacer
        </button>
        <button onClick={actions.redo} disabled={!actions.canRedo}>
          Rehacer
        </button>
        <span>
          Cambio {actions.historyIndex + 1} de {actions.historyLength}
        </span>
      </div>
      <textarea
        value={content.text}
        onChange={(e) => setContent({ text: e.target.value })}
      />
    </div>
  );
}

El historial se mantiene en memoria, no en localStorage. maxHistory limita cuántos estados se guardan para evitar consumo excesivo de memoria. Las propiedades canUndo y canRedo indican si hay estados disponibles en cada dirección.

Sincronización entre pestañas

Cuando el usuario tiene el mismo formulario abierto en múltiples pestañas, los cambios pueden sincronizarse automáticamente:

const [formData, setFormData] = useFormPersist('shared-doc', initialState, {
  sync: {
    enabled: true,
    strategy: 'latest-wins',
    onSync: (data, source) => {
      showNotification('Formulario actualizado desde otra pestaña');
    },
  },
});

La sincronización usa BroadcastChannel API cuando está disponible, con fallback a eventos de storage para navegadores más antiguos. La estrategia latest-wins significa que el cambio más reciente sobrescribe. Para escenarios más complejos, puedes proporcionar un conflictResolver custom:

sync: {
  enabled: true,
  conflictResolver: (local, remote) => {
    // Mergear arrays, preferir remote para otros campos
    return {
      ...remote,
      tags: [...new Set([...local.tags, ...remote.tags])],
    };
  },
}

Cumplimiento GDPR

Para cumplir con regulaciones de protección de datos, solo habilita persistencia después de obtener consentimiento del usuario:

function ConsentAwareForm() {
  const [consent, setConsent] = useState(loadConsentFromCookie());

  const [formData, setFormData, actions] = useFormPersist('form', initialState, {
    enabled: consent,
  });

  const handleRevokeConsent = () => {
    actions.clear();
    setConsent(false);
  };

  return (
    <div>
      <label>
        <input
          type="checkbox"
          checked={consent}
          onChange={(e) => {
            setConsent(e.target.checked);
            if (!e.target.checked) actions.clear();
          }}
        />
        Guardar mi progreso localmente
      </label>
      {/* campos del formulario */}
    </div>
  );
}

Cuando enabled es false, el hook se comporta como un useState normal sin operaciones de storage. La función clearGroup con prefijo vacío limpia todos los datos almacenados por la librería, implementando el derecho al olvido.

Formularios multi-paso

Para formularios divididos en múltiples pasos o páginas, persiste cada paso independientemente con claves relacionadas:

import { useFormPersist, clearGroup } from 'react-form-autosave';

function WizardStep1() {
  const [data, setData] = useFormPersist('wizard:step1', step1Initial);
  // ...
}

function WizardStep2() {
  const [data, setData] = useFormPersist('wizard:step2', step2Initial);
  // ...
}

function WizardComplete() {
  const handleComplete = async () => {
    await submitAllSteps();
    // Limpiar todos los pasos del wizard de una vez
    const clearedCount = clearGroup('wizard');
    console.log(`Limpiados ${clearedCount} formulario(s)`);
  };
  // ...
}

La función clearGroup acepta un prefijo y elimina todas las claves que empiecen con ese prefijo. clearGroup('wizard') elimina wizard:step1, wizard:step2, etc.

Indicador de estado de guardado

El componente AutoSaveIndicator muestra el estado actual de persistencia:

import { useFormPersist, AutoSaveIndicator } from 'react-form-autosave';

function Form() {
  const [data, setData, { lastSaved }] = useFormPersist('form', initialState);

  return (
    <form>
      <AutoSaveIndicator
        lastSaved={lastSaved}
        savedText="Guardado"
        savingText="Guardando..."
        notSavedText="Sin guardar"
        showTimestamp={true}
      />
      {/* campos del formulario */}
    </form>
  );
}

El componente acepta props para personalizar los textos, mostrar timestamp, y aplicar estilos custom.

Acciones disponibles

El tercer elemento del hook es un objeto con métodos y propiedades:

Información de estado:

  • isPersisted: boolean indicando si existen datos en storage
  • isRestored: boolean indicando si se restauraron datos al montar
  • lastSaved: timestamp del último guardado exitoso
  • isDirty: boolean indicando si el estado actual difiere del inicial
  • size: tamaño aproximado en bytes de los datos persistidos

Control de persistencia:

  • clear(): elimina datos persistidos sin afectar el estado actual
  • reset(): restaura al estado inicial y limpia storage
  • forceSave(): guarda inmediatamente sin esperar debounce
  • pause() / resume(): pausa y reanuda persistencia automática
  • revert(): restaura al último valor persistido, descartando cambios no guardados

Navegación de historial (si history está habilitado):

  • undo() / redo(): navegar por el historial
  • canUndo / canRedo: boolean indicando si hay estados disponibles
  • historyIndex / historyLength: posición y tamaño del historial

Utilidades:

  • getPersistedValue(): obtiene el valor persistido sin afectar el estado
  • withClear(handler): envuelve un handler para limpiar automáticamente después de ejecución exitosa

Testing

La librería exporta utilidades para simplificar testing de formularios con persistencia:

import {
  createMockStorage,
  seedPersistedData,
  getPersistedData,
  clearTestStorage,
  waitForPersist,
} from 'react-form-autosave/testing';

beforeEach(() => {
  clearTestStorage();
});

it('should restore persisted data on mount', () => {
  seedPersistedData('my-form', { name: 'John', email: 'john@test.com' });

  const { result } = renderHook(() =>
    useFormPersist('my-form', { name: '', email: '' })
  );

  expect(result.current[0].name).toBe('John');
  expect(result.current[2].isRestored).toBe(true);
});

it('should persist changes after debounce', async () => {
  const { result } = renderHook(() =>
    useFormPersist('my-form', { name: '' }, { debounce: 100 })
  );

  act(() => {
    result.current[1]({ name: 'Jane' });
  });

  await waitForPersist(150);

  expect(getPersistedData('my-form')).toEqual({ name: 'Jane' });
});

Las funciones seedPersistedData y getPersistedData permiten pre-poblar y verificar storage. waitForPersist espera el delay de debounce. createMockStorage crea un adaptador mock para verificar llamadas.

Tree-shaking y bundle size

La librería está diseñada para tree-shaking óptimo. El core está por debajo de 2KB gzipped. Features opcionales son imports separados:

// Core (siempre necesario)
import { useFormPersist } from 'react-form-autosave';

// Módulos opcionales
import { useHistory } from 'react-form-autosave/history';
import { useSync } from 'react-form-autosave/sync';
import { FormPersistDevTools } from 'react-form-autosave/devtools';
import { createMockStorage } from 'react-form-autosave/testing';

Si solo usas persistencia básica, los módulos adicionales no se incluyen en tu bundle.

Comparativa con alternativas

Feature react-form-autosave react-hook-form-persist use-local-storage-state redux-persist
Framework agnostic No (solo react-hook-form) No (solo Redux)
Zero dependencies No No
Bundle size <2KB ~1KB ~1KB ~10KB
Debounce No No No
Exclusión de campos No No
Expiración de datos No No
Undo/redo No No No
Sync entre pestañas No Requiere redux-state-sync
Migraciones de esquema No No
TypeScript Parcial
Soporte SSR Limitado
Utilidades de testing No No No
Mantenimiento activo Última actualización 2021

Cuándo usar esta librería

Usa react-form-autosave cuando necesites persistir estado de formularios sin adoptar una solución de gestión de estado global, cuando quieras features específicos de formularios como exclusión de campos y debounce out of the box, cuando el bundle size importa y quieras pagar solo por features que uses, o cuando necesites undo/redo o sync entre pestañas sin escribir código custom.

Considera alternativas si ya usas Redux y quieres persistir tu store entero (usa redux-persist), si usas react-hook-form y solo necesitas persistencia básica (usa react-hook-form-persist), o si necesitas persistir estado no relacionado con formularios en toda tu aplicación (usa use-local-storage-state o una solución de state management con persistencia).

Soporte de navegadores

La librería funciona en todos los navegadores modernos que soportan localStorage y sessionStorage: Chrome 80+, Firefox 75+, Safari 13+, Edge 80+. Para sincronización entre pestañas, se usa BroadcastChannel API donde está disponible, con fallback a eventos de storage para compatibilidad más amplia.

En entornos donde storage no está disponible, como algunas configuraciones de navegadores centradas en privacidad o cuando se excede la cuota de almacenamiento, la librería hace fallback a almacenamiento en memoria y continúa funcionando sin persistencia.

Test coverage

La librería mantiene 100% de cobertura de tests en todas las métricas. El suite de tests incluye 392 tests cubriendo toda la funcionalidad.

Métrica Cobertura
Statements 100%
Branches 100%
Functions 100%
Lines 100%

El paquete está publicado en NPM como react-form-autosave. El código fuente está en github.com/686f6c61/react-form-autosave. La demo interactiva está en react-form-autosave.onrender.com.