← Back

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.

FeatureuseStatelocalStorage
Stores dataYesYes
Updates the React UI automaticallyYesNo
Survives a page refreshNot by itselfUsually
Usually survives a browser restartNot by itselfYes
Stores objects directlyYesNo; 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

localhost:5173

Click Counter

0

You clicked 0 times

React updates the counter, but this version starts at zero after every full refresh.

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

localhost:5173
React state6
Saved JSON text"6"
After React commits the new count, the Effect saves the matching value.
  1. The user clicks the button.
  2. setClicks() updates React state.
  3. React commits the updated interface.
  4. The Effect runs because clicks changed.
  5. 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 valueInitial React state
"15"15
"42"42
Missing key: null0

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

localhost:5173

React LocalStorage Counter

18

You clicked 18 times

React state18
Saved JSON text"18"
This preview illustrates state and storage synchronization. Run the TSX project to test real persistence across refreshes.

When the Page Loads

  1. React calls the state initializer.
  2. The initializer reads saved-clicks.
  3. JSON restores the saved number, or the initializer returns zero.
  4. React renders the counter.
  5. The Effect synchronizes the current value with storage.

When the User Clicks

  1. The click handler updates state.
  2. React renders the new number.
  3. The Effect runs because clicks changed.
  4. 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();
MethodPurpose
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

  1. Open the Vite application.
  2. Open browser DevTools.
  3. Select the Application panel.
  4. Expand Local storage.
  5. Select the application's origin, such as http://localhost:5173.
KeyValue
saved-clicks18

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;
  }
});
  • unknown means the value has not yet been verified.
  • The typeof check 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

  1. Create the App component and a numeric counter state.
  2. Add Click me! and Reset buttons.
  3. Save clicks with an Effect and localStorage.setItem().
  4. Read the saved value with a lazy state initializer.
  5. Refresh the page and verify that the counter persists.
  6. Inspect saved-clicks in 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

ConceptRemember
useStateManages React component state
useEffectSynchronizes with external systems
localStoragePersists 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 initializerCalculates 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.

← Back↑ Back to the top