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 storageisRestored: boolean indicando si se restauraron datos al montarlastSaved: timestamp del último guardado exitosoisDirty: boolean indicando si el estado actual difiere del inicialsize: tamaño aproximado en bytes de los datos persistidos
Control de persistencia:
clear(): elimina datos persistidos sin afectar el estado actualreset(): restaura al estado inicial y limpia storageforceSave(): guarda inmediatamente sin esperar debouncepause()/resume(): pausa y reanuda persistencia automáticarevert(): restaura al último valor persistido, descartando cambios no guardados
Navegación de historial (si history está habilitado):
undo()/redo(): navegar por el historialcanUndo/canRedo: boolean indicando si hay estados disponibleshistoryIndex/historyLength: posición y tamaño del historial
Utilidades:
getPersistedValue(): obtiene el valor persistido sin afectar el estadowithClear(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 | Sí | No (solo react-hook-form) | Sí | No (solo Redux) |
| Zero dependencies | Sí | No | Sí | No |
| Bundle size | <2KB | ~1KB | ~1KB | ~10KB |
| Debounce | Sí | No | No | No |
| Exclusión de campos | Sí | Sí | No | No |
| Expiración de datos | Sí | Sí | No | No |
| Undo/redo | Sí | No | No | No |
| Sync entre pestañas | Sí | No | Sí | Requiere redux-state-sync |
| Migraciones de esquema | Sí | No | No | Sí |
| TypeScript | Sí | Parcial | Sí | Sí |
| Soporte SSR | Sí | Limitado | Sí | Sí |
| Utilidades de testing | Sí | No | No | No |
| Mantenimiento activo | Sí | Última actualización 2021 | Sí | Sí |
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.