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
const ROLE_PERMISSIONS = { admin: ['users:read', 'users:write', 'billing:read', 'billing:write'], manager: ['users:read', 'billing:read'], member: ['users:read'],
Role-based permissions middleware in Express
authorization
middleware
rbac
Intermediate
9 steps
javascript
function attachThousandSeparators(input, { locale = 'en-US' } = {}) { const formatter = new Intl.NumberFormat(locale); const groupSep = formatter.format(11111).replace(/\d/g, '')[0] || ','; const decimalSep = formatter.format(1.1).replace(/\d/g, '')[0] || '.';
Live thousand separators without losing the caret
dom
intl
caret-preservation
Advanced
8 steps
javascript
import { useReducer, useEffect } from "react"; const initialState = { status: "idle", data: null, error: null };
Building a data-fetching hook in React
custom-hooks
usereducer
data-fetching
Intermediate
9 steps
go
package config import ( "fmt"
A thread-safe config singleton in Go
singleton
concurrency
environment-variables
Intermediate
7 steps
javascript
const express = require('express'); const app = express(); app.get('/health', (req, res) => res.json({ status: 'ok' }));
Graceful shutdown in an Express server
graceful-shutdown
signal-handling
connection-tracking
Advanced
9 steps
rust
use std::sync::OnceLock; static CRC_TABLE: OnceLock<[u32; 256]> = OnceLock::new();
Table-driven CRC32 with lazy init in Rust
checksums
lazy-initialization
lookup-tables
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.