Przejdź do treści
lozyska-pulawy.pl — Informatyka dla Każdego

Jak używać localStorage w JavaScript i React, aby dane nie znikały po odświeżeniu

Osoba programująca na laptopie, widoczne dłonie i ekran
Rate this post

Czy szkic formularza, wybrany motyw albo filtr listy mają wrócić po odświeżeniu karty, czy mają zniknąć razem z pamięcią przeglądarki? Od tej decyzji zależy, czy wystarczy stan komponentu, czy trzeba zapisać dane w localStorage. Poniższy tekst prowadzi od typowego resetu interfejsu do zapisu w czystym JavaScript, odtworzenia stanu w React i prostego hooka, który nie powiela tego samego kodu w każdym komponencie. [1]

Formularz znika po odświeżeniu — skąd bierze się ten problem?

Typowy scenariusz: użytkownik wypełnia formularz i przypadkiem odświeża kartę

Wyobraź sobie formularz zgłoszenia: imię, opis problemu, wybrana kategoria i przełącznik „tylko pilne”. Osoba wypełnia połowę pól, wraca do poprzedniej karty, a potem przez przypadek naciska odświeżenie. Po chwili ekran jest czysty. Nic nie wybuchło, konsola milczy, a użytkownik i tak traci pracę.

Ten sam reset spotyka się w innych miejscach interfejsu. Koszyk gościa wraca do zera. Filtr „tylko dostępne” znika, choć przed chwilą zawężał listę. Ciemny motyw ustępuje jasnemu, bo tak ustawiono wartość startową w kodzie. Dla osoby po drugiej stronie ekranu wygląda to jak błąd aplikacji, nawet jeśli programista widzi tu tylko świeży start skryptu.

Odświeżenie nie musi oznaczać utraty takich danych. Warunek jest prosty: zanim karta zostanie przeładowana, aplikacja zapisuje wybrane wartości w miejscu, które przeżyje ponowne uruchomienie kodu. Dla preferencji interfejsu, szkicu formularza i prostego koszyka niezalogowanego użytkownika tym miejscem często jest localStorage.

Stan aplikacji istnieje tylko w pamięci procesu JavaScript

Zmienna w skrypcie i stan z useState żyją w pamięci aktualnie działającej strony. Dopóki karta pozostaje otwarta i skrypt nie zostanie uruchomiony od nowa, licznik rośnie, a pole tekstowe trzyma wpisany tekst. To działa szybko i nie wymaga żadnego magazynu przeglądarki.

Analogia z tablicą pomaga oddzielić te dwa światy. Stan komponentu przypomina notatkę mazakiem: widać ją od razu, łatwo ją zmienić, ale przy kolejnym uruchomieniu aplikacji tablica jest startowo pusta. Zapis danych w przeglądarce jest bliższy karteczce w szufladzie biurka — zostaje, o ile ktoś jej świadomie nie wyrzuci i o ile szuflada należy do tego samego miejsca, czyli tej samej strony, protokołu i portu.

Zanim sięgniesz po localStorage, rozdziel trzy potrzeby. Dane chwilowe, które mają zniknąć po zamknięciu karty, częściej pasują do sessionStorage. Ustawienia, które mają wrócić przy następnej wizycie w tej samej przeglądarce, pasują do localStorage. Dane, od których zależy konto, płatność albo ten sam widok na telefonie i laptopie, należą na serwer. Samo odświeżenie nie jest więc wyrokiem; wyrokiem jest brak zapisu w odpowiednim miejscu.

Dlaczego stan React i zwykłe zmienne nie przetrwają odświeżenia?

Co dzieje się z JavaScriptem podczas reloadu strony

Podczas reloadu przeglądarka porzuca dotychczasowy dokument. Zamyka działający kontekst JavaScript, buduje stronę od nowa i ponownie uruchamia skrypty. Zmienne globalne, domknięcia i obiekty trzymane tylko w pamięci wracają do wartości, które wynikają z kodu startowego. Nie ma tu „pamięci poprzedniego uruchomienia”, dopóki aplikacja sama jej nie odtworzy z trwałego źródła.

Dlatego licznik zapisany jako let count = 0 zawsze zaczyna od zera, nawet jeśli przed odświeżeniem pokazywał 18. Pole formularza bez atrybutu odtwarzającego wartość albo bez własnego odczytu z magazynu również startuje puste. To nie jest kaprys silnika. To konsekwencja modelu: strona po przeładowaniu jest nowym uruchomieniem programu w przeglądarce.

Dlaczego useState nie jest bazą danych

React po odświeżeniu montuje drzewo komponentów jeszcze raz. Każde useState(0) albo useState('') dostaje argument początkowy i od tego momentu liczy swój stan od nowa. Hook nie zagląda do poprzedniej sesji. Nie zapisuje nic na dysku. Jego zadaniem jest spójny interfejs w trakcie życia komponentu, nie archiwum między wizytami.

Prosty przykład pokazuje granicę. Komponent z polem „notatka” i stanem note reaguje na każdą literę. Do momentu przeładowania karty wszystko wygląda solidnie. Po reloadzie funkcja komponentu wykonuje się od początku, stan startuje od pustego ciągu i użytkownik widzi czyste pole. React nie zgubił danych przez błąd w useState. Po prostu nie dostał ich z powrotem, bo nikt ich wcześniej nie odłożył poza pamięć procesu. [3]

W tym miejscu wchodzi localStorage: magazyn par klucz–wartość dostępny dla skryptów tej samej domeny po ponownym uruchomieniu kodu. Odczyt na starcie przywraca ostatni szkic, a zapis przy zmianie pilnuje, żeby kolejne odświeżenie miało z czego odtworzyć widok.

localStorage jako trwały schowek przeglądarki — kiedy jest dobrym rozwiązaniem?

Co localStorage zapisuje i jak długo dane pozostają dostępne

API window.localStorage trzyma tekstowe pary klucz–wartość w przeglądarce użytkownika. Wpis zostaje po zamknięciu karty i po ponownym otwarciu strony, dopóki ktoś go nie usunie, nie wyczyści danych witryny albo przeglądarka nie odrzuci zapisu z powodu limitu albo trybu prywatnego. To nie jest synchronizacja z chmurą i nie jest to kopia na innym urządzeniu.

Wartość zawsze jest tekstem. Liczba 3 zapisana bez konwersji wróci jako "3". Obiekt preferencji, tablica produktów w koszyku i flaga logiczna wymagają świadomej zamiany na JSON i z powrotem. Jeśli pominiesz ten krok, później łatwo porównać napis "false" jak prawdę, bo niepusty string w JavaScript jest truthy.

Dobrze pasują tu wybory interfejsu: motyw jasny albo ciemny, język, ostatnio użyty filtr, zwinięty panel, szkic wiadomości, prosty koszyk gościa. Źle pasują dane, których utrata albo wyciek boli bardziej niż reset formularza: hasła, tokeny sesji, numery kart, pełne dane osobowe. Kod JavaScript na stronie może odczytać localStorage, więc przy podatności XSS schowek przeglądarki jest otwartą szufladą, nie sejfem.

Osoba programująca na laptopie, okulary leżą na stole
Źródło: Pexels | Autor: Daniil Komov

localStorage, sessionStorage, cookies i serwer — wybór zależny od celu

Zanim zapiszesz klucz, zadaj jedno pytanie: kto ma zobaczyć te dane i jak długo mają żyć? Odpowiedź zwykle wybiera magazyn lepiej niż przyzwyczajenie „zawsze localStorage”.

MiejsceKiedy ma sensCzego nie obiecuje
localStoragePreferencje i szkice w tej samej przeglądarce, także po zamknięciu kartySynchronizacji między urządzeniami i poufności wobec skryptów strony
sessionStorageDane jednej sesji karty, na przykład krok kreatora, który ma zniknąć po jej zamknięciuPowrotu po nowym otwarciu przeglądarki
cookieMała porcja danych wysyłana do serwera albo sterująca sesją po stronie HTTPWygodnego magazynu dużych obiektów interfejsu
serwerKonto, płatność, wspólna praca, ten sam stan na telefonie i laptopieDziałania offline bez dodatkowej warstwy synchronizacji

sessionStorage a localStorage różni przede wszystkim czas życia. Oba trzymają tekst w obrębie originu i oba są synchroniczne, ale sessionStorage jest związany z sesją karty. Zamknięcie karty kończy ten zapas. localStorage zostaje na kolejne wizyty. Żaden z nich nie zastąpi backendu, gdy dwa urządzenia mają widzieć ten sam koszyk albo gdy utrata wpisu oznacza błąd biznesowy, a nie tylko drobną niedogodność.

Zapis i odczyt localStorage w czystym JavaScript

setItem, getItem, removeItem i clear

Cztery metody pokrywają większość codziennej pracy. Zapis motywu wygląda tak:

localStorage.setItem('theme', 'dark');
const theme = localStorage.getItem('theme') || 'light';

getItem zwraca null, gdy klucza nie ma. Operator || albo jawne sprawdzenie daje wtedy sensowny domyślny motyw, zamiast wstawiać null do atrybutu dokumentu. Klucze trzymaj krótkie i jednoznaczne, na przykład ui.theme albo draft.contact, żeby dwa moduły nie nadpisały sobie nawzajem pola data.

Pojedynczy wpis usuwasz przez localStorage.removeItem('draft.contact'). Metoda clear() czyści cały localStorage danej domeny, nie tylko klucze Twojego komponentu. Na stronie z wieloma skryptami, wtyczką analityczną albo panelem administracyjnym taki szeroki gumka potrafi skasować cudze ustawienia. Sięgaj po nią tylko wtedy, gdy naprawdę chcesz opróżnić magazyn originu, na przykład w świadomej akcji „wyczyść dane tej witryny”.

Zapisywanie obiektów przez JSON.stringify

Filtr listy rzadko jest jednym napisem. Częściej to obiekt: status, sortowanie, fraza. Przed zapisem zamień go na tekst:

const filters = { onlyAvailable: true, sort: 'price' };
localStorage.setItem('catalog.filters', JSON.stringify(filters));

Bez JSON.stringify obiekt zamieni się w napis "[object Object]". Odczyt nic z tego nie odtworzy. Tablice produktów w prostym koszyku przechodzą tę samą drogę. Trzymaj w obiekcie tylko to, co naprawdę trzeba przywrócić: identyfikator, liczbę sztuk, wybrany wariant. Cenę i stan magazynowy i tak warto ponownie pobrać z serwera, bo localStorage nie jest źródłem prawdy o ofercie.

Bezpieczne odczytywanie danych przez JSON.parse

Odczyt musi wytrzymać dwie sytuacje: brak klucza oraz uszkodzony tekst. Ręczna edycja w devtools, starsza wersja aplikacji albo przerwany zapis potrafią zostawić coś, czego JSON.parse nie przełknie.

function readFilters() {
  const raw = localStorage.getItem('catalog.filters');
  if (!raw) {
    return { onlyAvailable: false, sort: 'name' };
  }
  try {
    const parsed = JSON.parse(raw);
    if (!parsed || typeof parsed !== 'object') {
      return { onlyAvailable: false, sort: 'name' };
    }
    return {
      onlyAvailable: Boolean(parsed.onlyAvailable),
      sort: parsed.sort === 'price' ? 'price' : 'name'
    };
  } catch {
    return { onlyAvailable: false, sort: 'name' };
  }
}

try...catch łapie błąd parsowania. Dodatkowe sprawdzenie kształtu obiektu chroni przed sytuacją, w której JSON jest poprawny, ale ma inne pola niż oczekuje aktualny kod. Po wdrożeniu nowej wersji filtrów stary zapis nie powinien wywracać listy.

Krótki scenariusz z katalogu: użytkownik włącza „tylko dostępne”, odświeża kartę i widzi ten sam widok. Zapis leci przy zmianie checkboxa, odczyt przy starcie skryptu. Reset widoku zostaje osobnym przyciskiem, który woła removeItem, a nie przypadkowym skutkiem reloadu.

React i localStorage: stan, który odtwarza się po odświeżeniu

Inicjalizacja raz, zapis przy zmianie

W Reactie problem wygląda konkretnie: użytkownik wybiera miasto w widgecie pogody, odświeża kartę i znowu widzi Warszawę, choć przed chwilą ustawił Gdańsk. Stan komponentu jest źródłem widoku, ale po reloadzie nie ma skąd wziąć ostatniego wyboru, dopóki sam go nie wczytasz.

Zbliżenie dłoni programisty piszącego kod na laptopie
Źródło: Pexels | Autor: cottonbro studio

Odczyt wstaw w leniwą inicjalizację useState. Funkcja startowa wykona się raz, przy montowaniu, a nie przy każdym renderze. Zapis zostaw efektowi, który reaguje na zmianę wartości. Dzięki temu render nie dotyka magazynu, a odświeżenie ma już gotowy klucz.

function CityWidget() {
  const [city, setCity] = useState(() => {
    if (typeof window === 'undefined') return 'Warszawa';
    return localStorage.getItem('widget.city') || 'Warszawa';
  });

  useEffect(() => {
    localStorage.setItem('widget.city', city);
  }, [city]);

  return (
    <label>
      Miasto
      <select value={city} onChange={(event) => setCity(event.target.value)}>
        <option value="Warszawa">Warszawa</option>
        <option value="Gdańsk">Gdańsk</option>
        <option value="Kraków">Kraków</option>
      </select>
    </label>
  );
}

Sprawdzenie window ma znaczenie w renderze serwerowym. Na serwerze localStorage nie istnieje. Jeśli pierwsza wartość HTML różni się od pierwszej wartości w przeglądarce, React zgłosi niezgodność hydracji. W aplikacji budowanej tylko w przeglądarce, na przykład Vite, leniwy odczyt jest w porządku. W Next.js albo Remixie bezpieczniej zacząć od wartości domyślnej i wczytać magazyn po montowaniu, godząc się na krótką podmianę, albo odłożyć ten fragment do komponentu klienckiego. [2]

Czego nie robić przy łączeniu stanu z magazynem

Nie wołaj setItem w trakcie renderu. Render może wykonać się częściej, niż myślisz, a zapis w tym miejscu łatwo zapętlić z aktualizacją stanu. Efekt z tablicą zależności [city] zapisuje dopiero wtedy, gdy wartość naprawdę się zmieni.

Nie trzymaj w stanie kopii, która żyje obok localStorage bez jednego kierunku zapisu. Albo stan jest źródłem widoku, a magazyn tylko jego cieniem, albo odczytujesz magazyn przy starcie i dalej pracujesz na stanie. Dwa niezależne zapisy — raz w onChange, raz w efekcie z inną wartością — prędzej czy później nadpiszą sobie wybór.

Przy obiekcie, na przykład { city, unit }, zapisuj całość jako JSON i przy odczycie sprawdź kształt, tak jak w funkcji czytającej filtry. Sam tekst miasta tego nie wymaga. Limit przeglądarki i tryb prywatny potrafią odrzucić setItem; efekt warto wtedy owinąć w try...catch, żeby widget dalej działał, nawet jeśli preferencja nie zostanie na kolejną wizytę.

Jest jeszcze błysk wartości domyślnej. Jeśli odczyt zrobisz dopiero w useEffect, pierwszy render pokaże Warszawę, a dopiero potem Gdańsk. Leniwa inicjalizacja na kliencie usuwa ten błysk. Odczyt po montowaniu zostaw tam, gdzie serwer i tak nie może znać localStorage.

Gdy kod zaczyna się powtarzać: własny hook useLocalStorage

Drugi i trzeci widget powtarzają ten sam rytuał: odczyt na starcie, ochrona przed brakiem window, zapis w efekcie, łapanie wyjątku. To już nie jest logika pogody. To logika magazynu. Hook zbiera ją w jedno miejsce i zostawia komponentowi tylko klucz oraz wartość początkową.

function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => readStored(key, initialValue));

  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // tryb prywatny albo pełny magazyn — UI zostaje, zapis odpada
    }
  }, [key, value]);

  const setStored = (next) => {
    setValue((prev) => (typeof next === 'function' ? next(prev) : next));
  };

  return [value, setStored];
}

function readStored(key, initialValue) {
  const fallback = () =>
    typeof initialValue === 'function' ? initialValue() : initialValue;

  if (typeof window === 'undefined') return fallback();

  try {
    const raw = localStorage.getItem(key);
    if (raw === null) return fallback();
    return JSON.parse(raw);
  } catch {
    return fallback();
  }
}

Komponent korzysta z hooka jak ze zwykłego stanu. Setter przyjmuje też funkcję, więc setUnit((prev) => prev === 'C' ? 'F' : 'C') działa tak samo jak w useState.

function WeatherPrefs() {
  const [prefs, setPrefs] = useLocalStorage('widget.weather', {
    city: 'Warszawa',
    unit: 'C'
  });

  return (
    <button
      type="button"
      onClick={() =>
        setPrefs((prev) => ({ ...prev, unit: prev.unit === 'C' ? 'F' : 'C' }))
      }
    >
      Jednostka: {prefs.unit}
    </button>
  );
}

Hook nie jest synchronizatorem kart. Zdarzenie storage dochodzi do innych kart tej samej domeny, ale nie do tej, która właśnie zapisała wartość. Jeśli dwa otwarte panele mają widzieć ten sam wybór bez odświeżenia, trzeba osobno nasłuchiwać window i porównać event.key. Do jednego widgetu na jednej karcie ten nasłuch jest zbędny.

Zostaw w hooku też świadome granice. Klucz trzymaj stabilny; zmiana key w trakcie życia komponentu zapisze nową parę, ale nie usunie starej. Dużych list nie zapisuj przy każdej literze bez potrzeby — dla krótkiej preferencji efekt wystarcza, dla długiego tekstu lepiej zapisać po krótkiej pauzie albo przy blurze. I nie wkładaj tu niczego, czego nie chciałbyś pokazać każdemu skryptowi na stronie: hook ułatwia zapis, nie zmienia tego, czym localStorage jest.

Pułapki, które wychodzą dopiero przy żywym widgecie

Hook zamyka rytuał odczytu i zapisu, ale nie pilnuje tego, co włożysz do wartości. Najczęstszy zgrzyt nie jest w useState, tylko w tym, że efekt odpala się także przy pierwszym montowaniu. Jeśli w magazynie nie było klucza, komponent i tak zapisze wartość domyślną. Dla motywu to zwykle plus: kolejna wizyta dostaje już konkret. Dla szkicu, którego użytkownik jeszcze nie zaczął, taki zapis zostawia pusty obiekt i utrudnia odróżnienie „nigdy nie pisał” od „wyczyścił formularz”. Zapis warunkowy — tylko gdy wartość różni się od startowej albo gdy użytkownik coś zmienił — zostaw w komponencie, nie upychaj na siłę w każdym hooku.

Laptop z kodem JavaScript na ekranie z odbiciem światła
Źródło: Pexels | Autor: Christina Morillo

Druga pułapka to obiekt tworzony w renderze i wkładany z powrotem do stanu. useLocalStorage('widget.weather', { city: 'Warszawa', unit: 'C' }) jest w porządku, bo wartość początkowa schodzi do leniwego odczytu raz. Źle robi się dopiero wtedy, gdy po odczycie „poprawiasz” dane w trakcie renderu i wołasz setter. Render ma czytać stan, efekt ma pisać do magazynu. Trzecia ścieżka, która liczy obiekt na nowo i od razu go zapisuje, łatwo kończy się pętlą albo miganiem jednostki z C na F.

Przy długim tekście nie zapisuj każdej litery, jeśli nie musisz. Efekt podpięty pod cały szkic wiadomości uderza w dysk przeglądarki przy każdym onChange. Krótka preferencja, jak miasto albo jednostka, tego nie poczuje. Pole opisu lepiej zrzucić po pauzie albo przy opuszczeniu pola. Hook może zostać taki sam; zmienia się tylko moment, w którym komponent woła setter.

Zwróć też uwagę na kształt po wdrożeniu nowej wersji. Hook z przykładu zwraca to, co dał JSON.parse, bez sprawdzania pól. Widget pogody z jednym przyciskiem to przeżyje. Formularz z kilkoma polami już nie: stary zapis bez unit wywali się na prev.unit. Walidację, którą miała funkcja czytająca filtry, warto przenieść do readStored albo zrobić tuż po odczycie, zanim stan pójdzie do widoku. Zły JSON ma wrócić do wartości domyślnej, a nie wyłożyć cały komponent.

Na koniec granica karty. Dwa otwarte panele nie zaktualizują się same, bo zdarzenie storage nie wraca do karty, która zapisała. Jeśli produkt naprawdę wymaga wspólnego wyboru bez odświeżenia, nasłuch jest osobną funkcją: porównanie event.key, parsowanie event.newValue i setValue tylko gdy klucz się zgadza. Nie dokładaj tego nasłuchu „na zapas” do hooka używanego przez jeden select.

Co zostawić w komponencie, a co wynieść do hooka

Zostaw w hooku tylko to, co powtarza się bez zmiany: brak window, odczyt raz na starcie, zapis po zmianie, łapanie wyjątku z pełnego magazynu. W komponencie zostaw decyzję, której hook nie zna: czy pusta wartość w ogóle ma trafić na dysk, czy tekst czeka na blur, czy stary obiekt da się jeszcze pokazać.

Praktyczny test przed mergem jest krótki. Ustaw wartość, odśwież kartę, sprawdź, czy widok wraca bez błysku domyślnej. Wyczyść klucz ręcznie i sprawdź, czy aplikacja wstaje na wartości początkowej. Wklej w devtools uszkodzony JSON i uszkodzony kształt — oba mają skończyć się domyślną wartością, nie białym ekranem. Potem otwórz drugą kartę tylko wtedy, gdy ten widok naprawdę ma być wspólny.

Dla jednego selecta z miastem wystarczy leniwy useState i efekt z setItem. Hook opłaca się od drugiego takiego widgetu, nie wcześniej. Magazyn nadal trzyma tekst tej domeny w przeglądarce użytkownika — hook nie zamienia go w miejsce na dane konta ani na stan, który musi być taki sam na telefonie i laptopie.

Najważniejsze punkty

  • Stan zapisany wyłącznie w zwykłej zmiennej lub useState nie przetrwa odświeżenia strony.
  • localStorage przechowuje tekstowe pary klucz–wartość i nadaje się między innymi do preferencji, filtrów oraz szkiców formularzy.
  • Obiekty i tablice trzeba zapisywać przez JSON.stringify(), a odczytywać przez JSON.parse() z obsługą błędnych danych.
  • sessionStorage pasuje do danych jednej sesji karty, natomiast dane konta, płatności i synchronizacji między urządzeniami powinny trafić na serwer.
  • W localStorage nie należy przechowywać haseł, tokenów sesji, numerów kart ani pełnych danych osobowych.

Źródła