React.js + Next.js · Module 3. Forms and Effects · Lesson 14 · Point 14
Working with localStorage in React and TypeScript
React + TypeScript · GoIT Dev Notes
This lesson explains how to preserve React state between page loads by synchronizing it with the browser's localStorage.
You will save and restore values, convert data with JSON, use a lazy state initializer, build a persistent counter, inspect storage in DevTools, and handle invalid saved data safely.
REACT · 01
1. What Is localStorage?
React state is reset when the page is fully reloaded. The browser's localStorage can remember string values between browser sessions for the same origin.
Typical uses include a preferred language, a color theme, selected filters, a draft search query, or a simple counter.
| Feature | useState | localStorage |
|---|---|---|
| Stores data | Yes | Yes |
| Updates the React UI automatically | Yes | No |
| Survives a page refresh | Not by itself | Usually |
| Usually survives a browser restart | Not by itself | Yes |
| Stores objects directly | Yes | No; values are strings |
REACT · 02
2. Save Data with setItem()
localStorage.setItem("key", "value");
localStorage.setItem("username", "Nikita");
localStorage.setItem("language", "en");
localStorage.setItem("theme", "dark");
Each entry contains a key that identifies the data and a string value. Saving another value with the same key replaces the previous one.
localStorage.setItem("theme", "dark");
localStorage.setItem("theme", "light");
// The stored theme is now "light".
REACT · 03
3. Read Data with getItem()
localStorage.setItem("city", "Paris");
const city = localStorage.getItem("city");
console.log(city); // "Paris"
If the requested key does not exist, getItem() returns null. Its TypeScript return type is string | null.
const username = localStorage.getItem("username");
if (username !== null) {
console.log("Welcome, " + username);
} else {
console.log("No username saved.");
}
The null check narrows the type. Inside the first branch, TypeScript knows that username is a string.
REACT · 04
4. Prepare Data with JSON.stringify()
React state may contain numbers, booleans, arrays, and objects, but localStorage stores strings. JSON.stringify() converts a supported JavaScript value into JSON text.
const clicks = 10;
const savedClicks = JSON.stringify(clicks);
console.log(savedClicks); // "10"
console.log(typeof savedClicks); // "string"
localStorage.setItem("saved-clicks", savedClicks);
Example with an Object
const user = {
name: "Nikita",
age: 41,
language: "pl"
};
localStorage.setItem("user", JSON.stringify(user));
Directly passing an ordinary object would produce an unhelpful value such as "[object Object]". JSON preserves the object's data structure as text.
REACT · 05
5. Restore Data with JSON.parse()
JSON.parse() converts valid JSON text back into a JavaScript value.
const saved = "25";
const clicks = JSON.parse(saved);
console.log(clicks); // 25
console.log(typeof clicks); // "number"
Complete Save and Read Sequence
const clicks = 15;
localStorage.setItem(
"saved-clicks",
JSON.stringify(clicks)
);
const savedClicks = localStorage.getItem("saved-clicks");
if (savedClicks !== null) {
const restoredClicks = JSON.parse(savedClicks);
console.log(restoredClicks); // 15
}
REACT · 06
6. Create a React Counter
import { useState } from "react";
export default function App() {
const [clicks, setClicks] = useState<number>(0);
function handleClick() {
setClicks(clicks + 1);
}
function handleReset() {
setClicks(0);
}
return (
<div>
<h1>Click Counter</h1>
<p>You clicked {clicks} times</p>
<button onClick={handleClick}>Click me!</button>
<button onClick={handleReset}>Reset</button>
</div>
);
}
Browser Result
REACT · 07
7. Save Changes Automatically with useEffect
localStorage is external to React. An Effect can synchronize it after the state changes.
import { useEffect, useState } from "react";
export default function App() {
const [clicks, setClicks] = useState<number>(0);
useEffect(function () {
localStorage.setItem(
"saved-clicks",
JSON.stringify(clicks)
);
}, [clicks]);
function handleClick() {
setClicks(clicks + 1);
}
return (
<button onClick={handleClick}>
You clicked {clicks} times
</button>
);
}
Browser Result
- The user clicks the button.
setClicks()updates React state.- React commits the updated interface.
- The Effect runs because
clickschanged. - The new value is saved as JSON text.
Saving now works, but the initial state still starts at zero. The next step restores the saved value.
REACT · 08
8. Read Saved State with Lazy Initialization
useState can receive an initializer function. React calls it while establishing the component's initial state.
const [clicks, setClicks] = useState<number>(function () {
const savedClicks = localStorage.getItem("saved-clicks");
if (savedClicks !== null) {
return JSON.parse(savedClicks);
}
return 0;
});
| Stored value | Initial React state |
|---|---|
"15" | 15 |
"42" | 42 |
Missing key: null | 0 |
In development, StrictMode may call initializer functions twice to help detect impurities. The initializer should calculate and return a value; it should not write to storage or cause other side effects.
REACT · 09
9. Why Initialize State Instead of Reading in an Effect?
For a client-only Vite application, lazy initialization lets the first render use the saved value.
const [clicks, setClicks] = useState<number>(function () {
const saved = localStorage.getItem("saved-clicks");
return saved !== null ? JSON.parse(saved) : 0;
});
Reading in an Effect starts with a fallback and updates the state after the first render:
const [clicks, setClicks] = useState<number>(0);
useEffect(function () {
const saved = localStorage.getItem("saved-clicks");
if (saved !== null) {
setClicks(JSON.parse(saved));
}
}, []);
This version may briefly display zero and requires another render.
REACT · 10
10. Complete Project: Persistent React Counter
The complete counter restores its initial value, saves every change, and keeps the React interface synchronized.
Project Structure
react-localstorage/
├── src/
│ ├── App.tsx
│ └── main.tsx
├── index.html
├── package.json
└── tsconfig.json
src/App.tsx
import { useEffect, useState } from "react";
export default function App() {
const [clicks, setClicks] = useState<number>(function () {
const savedClicks = localStorage.getItem("saved-clicks");
if (savedClicks !== null) {
return JSON.parse(savedClicks);
}
return 0;
});
useEffect(function () {
localStorage.setItem(
"saved-clicks",
JSON.stringify(clicks)
);
}, [clicks]);
function handleClick() {
setClicks(clicks + 1);
}
function handleReset() {
setClicks(0);
}
return (
<div>
<h1>React LocalStorage Counter</h1>
<p>You clicked {clicks} times</p>
<button onClick={handleClick}>Click me!</button>
<button onClick={handleReset}>Reset</button>
</div>
);
}
Browser Result
When the Page Loads
- React calls the state initializer.
- The initializer reads
saved-clicks. - JSON restores the saved number, or the initializer returns zero.
- React renders the counter.
- The Effect synchronizes the current value with storage.
When the User Clicks
- The click handler updates state.
- React renders the new number.
- The Effect runs because
clickschanged. - The new value is saved.
REACT · 11
11. Reset the Counter
function handleReset() {
setClicks(0);
}
The handler does not need to call localStorage.setItem(). Updating state causes another render, and the existing Effect saves "0".
This keeps React state as the source of truth for the interface and centralizes persistence in one synchronization Effect.
REACT · 12
12. Remove One Entry with removeItem()
localStorage.setItem("username", "Nikita");
localStorage.setItem("theme", "dark");
localStorage.removeItem("username");
Only username is removed. The theme remains.
REACT · 13
13. Remove All Entries with clear()
localStorage.setItem("username", "Nikita");
localStorage.setItem("theme", "dark");
localStorage.clear();
| Method | Purpose |
|---|---|
setItem(key, value) | Save or replace an entry |
getItem(key) | Read an entry |
removeItem(key) | Remove one entry |
clear() | Remove every entry for the origin |
Use clear() carefully. Other features on the same origin may depend on their own saved keys.
REACT · 14
14. Inspect localStorage in Chrome DevTools
- Open the Vite application.
- Open browser DevTools.
- Select the Application panel.
- Expand Local storage.
- Select the application's origin, such as
http://localhost:5173.
| Key | Value |
|---|---|
saved-clicks | 18 |
Click the counter button and watch the value change. Refresh the page to confirm that the counter restores it.
REACT · 15
15. Common Mistakes
Mistake 1 — Writing During Rendering
// Incorrect: side effect during rendering
localStorage.setItem("saved-clicks", JSON.stringify(clicks));
Synchronize after rendering with an Effect:
useEffect(function () {
localStorage.setItem("saved-clicks", JSON.stringify(clicks));
}, [clicks]);
Mistake 2 — Forgetting JSON.parse()
getItem() returns text or null. The string "10" is not the number 10.
Mistake 3 — Forgetting the Null Check
const saved = localStorage.getItem("saved-clicks");
if (saved !== null) {
const clicks = JSON.parse(saved);
console.log(clicks);
}
Mistake 4 — Expecting Storage to Update the UI
const [clicks, setClicks] = useState(5);
localStorage.setItem("saved-clicks", "20");
// clicks is still 5
setClicks(20); // This updates the React UI.
Mistake 5 — Forgetting the Dependency Array
Without [clicks], the Effect runs after every committed render. The dependency states exactly when this synchronization is needed.
REACT · 16
16. localStorage Security
localStorage is useful for non-sensitive preferences such as themes, languages, filters, and recent searches.
Do not treat it as a secure vault. Avoid storing passwords, PINs, payment card secrets, or sensitive personal information.
JavaScript running on the same origin can read the stored data. That includes malicious code introduced through a cross-site scripting vulnerability.
REACT · 17
17. Improve Reliability by Handling Invalid JSON
Storage may contain unexpected or manually edited data. Use try...catch and validate the parsed value before accepting it.
const [clicks, setClicks] = useState<number>(function () {
try {
const saved = localStorage.getItem("saved-clicks");
if (saved === null) {
return 0;
}
const parsed: unknown = JSON.parse(saved);
if (
typeof parsed === "number" &&
Number.isSafeInteger(parsed) &&
parsed >= 0
) {
return parsed;
}
return 0;
} catch (error) {
console.error("Could not restore counter:", error);
return 0;
}
});
unknownmeans the value has not yet been verified.- The
typeofcheck confirms it is a number. Number.isSafeInteger()rejects fractions, infinity, and unsafe integers.- The final comparison rejects negative counter values.
In production code, writes can also fail because of browser restrictions or storage limits. Catch write errors when the application needs to recover gracefully.
REACT · 18
18. Practical Exercise
- Create the
Appcomponent and a numeric counter state. - Add Click me! and Reset buttons.
- Save
clickswith an Effect andlocalStorage.setItem(). - Read the saved value with a lazy state initializer.
- Refresh the page and verify that the counter persists.
- Inspect
saved-clicksin browser DevTools.
Bonus Exercise
- Increase the counter by five instead of one.
- Display a message when the count reaches 50.
- Ensure the new count still survives a page refresh.
This combines state, events, conditional rendering, Effects, JSON, and browser storage.
REACT · 19
19. Key Takeaways
| Concept | Remember |
|---|---|
useState | Manages React component state |
useEffect | Synchronizes with external systems |
localStorage | Persists strings for a browser origin |
setItem() | Saves or replaces data |
getItem() | Reads a string or returns null |
JSON.stringify() | Converts a value to JSON text |
JSON.parse() | Restores a value from valid JSON |
| Lazy initializer | Calculates the initial state once per component initialization |
[clicks] | Reruns the synchronization when the counter changes |
removeItem() | Deletes one storage entry |
clear() | Deletes all entries for the origin |
The Reusable Pattern
const [clicks, setClicks] = useState<number>(function () {
const saved = localStorage.getItem("saved-clicks");
return saved !== null ? JSON.parse(saved) : 0;
});
useEffect(function () {
localStorage.setItem(
"saved-clicks",
JSON.stringify(clicks)
);
}, [clicks]);
Main idea: React state controls the interface. The initializer restores saved data, and the Effect keeps browser storage synchronized with later state changes.