javascript
45 lines · 9 steps
A React hook that persists form state to localStorage
usePersistentForm keeps form values in localStorage with debounced writes so a refresh never loses what the user typed.
Explained by
highlit
1import { useState, useEffect, useCallback, useRef } from "react";
2
3export function usePersistentForm(storageKey, initialValues) {
4 const [values, setValues] = useState(() => {
5 try {
6 const stored = window.localStorage.getItem(storageKey);
7 return stored ? { ...initialValues, ...JSON.parse(stored) } : initialValues;
8 } catch {
9 return initialValues;
10 }
11 });
12
13 const saveTimer = useRef(null);
14
15 useEffect(() => {
16 clearTimeout(saveTimer.current);
17 saveTimer.current = setTimeout(() => {
18 try {
19 window.localStorage.setItem(storageKey, JSON.stringify(values));
20 } catch {
21 /* quota exceeded or storage unavailable */
22 }
23 }, 400);
24 return () => clearTimeout(saveTimer.current);
25 }, [storageKey, values]);
26
27 const handleChange = useCallback((event) => {
28 const { name, type, value, checked } = event.target;
29 setValues((prev) => ({
30 ...prev,
31 [name]: type === "checkbox" ? checked : value,
32 }));
33 }, []);
34
35 const setField = useCallback((name, value) => {
36 setValues((prev) => ({ ...prev, [name]: value }));
37 }, []);
38
39 const reset = useCallback(() => {
40 window.localStorage.removeItem(storageKey);
41 setValues(initialValues);
42 }, [storageKey, initialValues]);
43
44 return { values, handleChange, setField, reset };
45}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1A lazy initializer in useState lets you hydrate from storage exactly once without re-reading on every render.
- 2Debouncing writes through a ref'd timer avoids hammering localStorage on every keystroke.
- 3Wrapping storage access in try/catch keeps the hook resilient when storage is full or unavailable.
Related explainers
javascript
function evaluate(expression) { const tokens = tokenize(expression); let pos = 0;
Building a recursive descent calculator
parsing
recursion
operator-precedence
Intermediate
8 steps
javascript
import { useState, useEffect, useCallback, useRef } from 'react'; const cache = new Map(); const inflight = new Map();
Building a stale-while-revalidate hook in React
caching
request-deduplication
custom-hooks
Advanced
10 steps
go
func (w *Watcher) resetDebounce(d time.Duration) { if !w.timer.Stop() { select { case <-w.timer.C:
Debouncing a stream of events in Go
debounce
timers
channels
Advanced
7 steps
typescript
import { useEffect, useState } from "react"; interface Section { id: string;
Building a scroll-spy hook in React
custom-hooks
intersectionobserver
dom-observation
Intermediate
8 steps
typescript
import { Injectable, Scope, Inject, NotFoundException } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { Request } from 'express'; import { DataSource } from 'typeorm';
Per-tenant database connections in NestJS
multi-tenancy
connection-pooling
dependency-injection
Advanced
8 steps
javascript
import { useEffect, useRef, useState } from 'react'; export function useDelayedFlag(active, delay = 300) { const [visible, setVisible] = useState(false);
Delaying a loading spinner with a React hook
custom-hooks
debouncing
cleanup
Intermediate
8 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/a-react-hook-that-persists-form-state-to-localstorage-explained-javascript-a662/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.