Poprzednio poznaliśmy już podstawy komponentów i podstawowych hooków Reacta. Teraz napiszemy swój własny hook do obsługi localStorage czyli zapisywania i odczytywania pewnych danych w przeglądarce. Użyjemy wcześniej poznanych hooków useState i useEffect stawiając kolejny krok w drodze do napisania dobrego projektu ToDo w bibliotece React.

Czym jest obiekt localStorage – krótkie wyjaśnienie

Obiekt localStorage znajdujący się wewnątrz obiektu window to specyficzne API pozwalające nam zapisywać w przeglądarce pewne proste dane na zasadzie „klucz-wartość” oraz odczytywać je.

Nie jest to baza danych po stronie serwera, do której mamy dostęp, ale dane po stronie klienta, które w dodatku zależą od przeglądarki (zapisują się w tej, z której korzystamy odwiedzając stronę), z czasem są usuwane, nie jest to więc rozwiązanie do wszystkiego, raczej do specyficznych i doraźnych sytuacji, w których chcielibyśmy coś tam zapisać.

W localStorage mamy 4 metody. Metoda setItem służy do zapisu danych:

localStorage.setItem("myCat", "Tom");

Metoda getItem służy do odczytywania danych:

const cat = localStorage.getItem("myCat");

Metoda removeItem usuwa element o danej nazwie:

localStorage.removeItem("myCat");

Metoda clear czyści cały nasz localStorage:

localStorage.clear();

Do elementów naszego storage możemy też odnosić się „po kropce”, jak w poniższym kodzie, który o dziwo działa:

if (localStorage.clickcount) {
  localStorage.clickcount = Number(localStorage.clickcount) + 1;
} else {
  localStorage.clickcount = 1;
}

Tworzymy CounterLS – pierwsze kroki, kilka słów o useEffect i renderowaniu

Pracujemy w projekcie z poprzedniej części tutoriala. Tworzymy nowy plik o nazwie CounterLS.js w folderze components, tworzymy komponent o nazwie CounterLS:

import React, { useState } from 'react';

function CounterLS() {
    console.log(`Komponent Counter dodany do strony.`);
    const [count, setCount] = useState(0);
    
    const increment = () => {
        setCount(count + 1);
    };

    const decrement = () => {
        setCount(count - 1); 
    };
    
    return (
        <div>
            <h1>Counter: {count}</h1>
            <button onClick={increment}>Zwiększ</button>
            <button onClick={decrement}>Zmniejsz</button>
        </div>
    );
}

export default CounterLS;

Nie powinno być tu niczego nowego, czego byśmy nie znali. Co najwyżej dziwić nas może top-level console.log, który wykona się jeden raz – w momencie, gdy komponent zostanie do strony dodany. To go dodajmy – w pliku App.js:

import React from 'react';
import CounterLS from './components/CounterLS';
function App() {
  
  return (
    <>
      <CounterLS/>
    </>
  );
}

export default App;

Teraz, jeżeli jeszcze tego nie zrobiliśmy, uruchamiamy naszą aplikację:

npm start

Zobaczmy, czy nasz licznik działa, jak należy. Zobaczmy też konsolę deweloperską, czy informacja o dodaniu komponentu się wyświetla. Powinna się wyświetlać.

Jeżeli mamy bystre oko, możemy zauważyć, że wiadomość „Komponent Counter dodany do strony.” wyświetla się dwa razy. Dlaczego? Cóż, odpowiedź znajduje się w pliku index.js, który spaja naszą aplikację z App.js:

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

Jeżeli chcemy, możemy się wgłębić w to, jak to wszystko działa, czym jest ReactDOM.createRoot i tak dalej. Jeżeli nie – nie musimy. Tak czy inaczej naszą uwagę powinien zwrócić ten fragment:

<React.StrictMode>
    <App />
  </React.StrictMode>

React StrictMode renderuje każdy komponent podwójnie (podczas developmentu, ale już nie na produkcji). Robi to po to, aby lepiej debugować i testować nasze eksperymenty. Pierwszy render jest dla testu i go nie widzimy, aczkolwiek zapisuje się w konsoli.

Swoją drogą popełniłem pewien błąd, nasz komponent nazywa się CounterLS, nie Counter. Naprawmy go. Przy okazji – użyjemy sobie useEffect z pustą tablicą zależności:

import React, { useState, useEffect } from 'react';

function CounterLS() {
    //console.log(`Komponent Counter dodany do strony.`);
    const [count, setCount] = useState(0);
    useEffect( () => {
        console.log(`Komponent CounterLS dodany do strony.`);
    }, []);

    
    const increment = () => {
        setCount(count + 1);
    };

    const decrement = () => {
        setCount(count - 1); 
    };
    
    return (
        <div>
            <h1>Counter: {count}</h1>
            <button onClick={increment}>Zwiększ</button>
            <button onClick={decrement}>Zmniejsz</button>
        </div>
    );
}

export default CounterLS;

Jeżeli pamiętamy dobrze, jak działa hook useEffect, to powinniśmy wiedzieć, że ten efekt, z pustą tablicą zależności, wykona się tylko raz – na samym początku, logując informację o dodaniu komponentu. No dobra – dwa razy, ze względu na React StrictMode. Jeżeli zaś pozbawimy go tablicy zależności w ogóle – będzie wykonywał się przy każdym renderze, na przykład wymuszonym zmianą stanu, który jest gdzieś wyświetlany.

Zobaczmy na ten kod:

import React, { useState, useEffect } from 'react';

function CounterLS() {
    //console.log(`Komponent Counter dodany do strony.`);
    const [count, setCount] = useState(0);
    useEffect( () => {
        console.log(`Komponent CounterLS dodany do strony.`);
    }, []);
    useEffect( () => {
        console.log(`Komponent CounterLS został wyrenderowany`);
    }, );

    
    const increment = () => {
        setCount(count + 1);
    };

    const decrement = () => {
        setCount(count - 1); 
    };
    
    return (
        <div>
            <h1>Counter: {count}</h1>
            <button onClick={increment}>Zwiększ</button>
            <button onClick={decrement}>Zmniejsz</button>
        </div>
    );
}

export default CounterLS;

Tutaj dostaniemy przy pierwszym renderze dwie wiadomości (x 2 w StrictMode):

>Komponent CounterLS dodany do strony.
>Komponent CounterLS został wyrenderowany

Wynika to z naszych efektów:

useEffect( () => {
        console.log(`Komponent CounterLS dodany do strony.`);
    }, []);
useEffect( () => {
        console.log(`Komponent CounterLS został wyrenderowany`);
    }, );

Pierwszy efekt, z pustą tablicą zależności, działa przy dodaniu elementu do strony. Drugi – przy renderowaniu, zaś dodanie elementu do strony obejmuje także wyrenderowanie obiektu.

Jeżeli będziemy się dalej bawić przyciskami, dostaniemy już tylko informację:

>Komponent CounterLS został wyrenderowany

Pochodzi ona z tego efektu, bez tablicy zależności, a zatem działającego przy każdym renderowaniu:

useEffect( () => {
        console.log(`Komponent CounterLS został wyrenderowany`);
    }, );

Renderowanie wynika ze względu na to, że wyświetlamy count, który się zmienia:

return (
        <div>
            <h1>Counter: {count}</h1>
            <button onClick={increment}>Zwiększ</button>
            <button onClick={decrement}>Zmniejsz</button>
        </div>
    );

Warto to rozumieć. Rzecz jasna, możemy przekazać tablicę zależności z informacją, aby efekt wywoływać tylko wtedy, gdy nastąpi renderowanie wymuszone zmianą określonego przez nas stanu:

useEffect( () => {
        console.log(`Komponent CounterLS wyrenderowany ze względu na zmianę stanu count`);
    }, [count]);

Ten efekt działa tylko wtedy, gdy zmieni się stan count. Jeżeli rozumiemy to wszystko, rozumiemy hook useEffect, a to naprawdę dużo.

Licznik – dodajemy obsługę localStorage

Użyjemy teraz hooka useEffect, nasłuchującego na zmianę stanu count, aby zapisywał ten stan do localStorage pod kluczem „counter”. Następnie sprawdzimy, czy poprawnie zapisuje – możemy odnaleźć localStorage w narzędziach developerskich, ale prościej będzie to po prostu wylogować:

useEffect( () => {
        localStorage.setItem('counter', count);
        console.log(localStorage.counter);
    }, [count]);

Teraz wypadałoby sprawdzić, czy mamy coś zapisanego w localStorage pod kluczem „counter” i użyć tej wartości, jeżeli ona istnieje w useState. Jeżeli nie istnieje – użyć wartości domyślnej (0):

 const [count, setCount] = useState(() => {
        const saved = localStorage.getItem("counter");
        const initialValue = JSON.parse(saved);
        return initialValue || 0;
      });

Jak widać, do useState zamiast prostej wartości domyślnej 0 przekazujemy funkcję, która sprawdza, czy w localStorage pod kluczem „counter” coś mamy. Jeżeli mamy – zwraca tę wartość jako stan. Jeżeli nie – zwraca 0.

Cały nasz komponent powinien teraz wyglądać tak:

import React, { useState, useEffect } from 'react';

function CounterLS() {

    const [count, setCount] = useState(() => {
        const saved = localStorage.getItem("counter");
        const initialValue = JSON.parse(saved);
        return initialValue || 0;
      });
    
    useEffect( () => {
        localStorage.setItem('counter', count);
        console.log(localStorage.counter);
    }, [count]);

    
    const increment = () => {
        setCount(count + 1);
    };

    const decrement = () => {
        setCount(count - 1); 
    };
    
    return (
        <div>
            <h1>Counter: {count}</h1>
            <button onClick={increment}>Zwiększ</button>
            <button onClick={decrement}>Zmniejsz</button>
        </div>
    );
}

export default CounterLS;

Możemy teraz odpalić aplikację, pobawić się licznikiem, następnie dać odśwież – i nasza wartość zostaje. Rozwiązanie dobre, aczkolwiek spróbujmy dodać teraz dwa Countery do pliku App.js:

import React from 'react';
import CounterLS from './components/CounterLS';
function App() {
  
  return (
    <>
      <CounterLS/>
      <CounterLS/>
    </>
  );
}

export default App;

I co widzimy – te elementy będą miały te samą wartość. Stan mają różny, dlatego możemy jeden np. pomniejszyć aż do wartości minusowych, drugi zaś zwiększyć nawet i do 10. Po odświeżeniu jednak – ten pierwszy zapisuje się do localStorage i drugi korzysta z tego pierwszego.

Raz jeszcze – stan dla każdego komponentu jest indywidualny, ale jako że korzystają z elementu localStorage o takim samym kluczu, powoduje to problemy. Jak to rozwiązać?

Najlepiej nadać im props o nazwie klucza, jakiego localStorage ma używać. Ja go nazwałem ls_key:

(...)
  return (
    <>
      <CounterLS ls_key={"counter1"}/>
      <CounterLS ls_key={"counter2"}/>
    </>
  );
}

export default App;

Teraz wyciągamy ten klucz jako prop w naszym pliku CounterLS.js:

function CounterLS({ls_key}) {
(...)

Teraz używamy go w hooku useState:

function CounterLS({ls_key}) {

    const [count, setCount] = useState(() => {
        const saved = localStorage.getItem(ls_key);
        const initialValue = JSON.parse(saved);
        return initialValue || 0;
      });
    

Oraz w useEffect:

function CounterLS({ls_key}) {

    const [count, setCount] = useState(() => {
        const saved = localStorage.getItem(ls_key);
        const initialValue = JSON.parse(saved);
        return initialValue || 0;
      });
    
    useEffect( () => {
        localStorage.setItem(ls_key, count);
        console.log(localStorage.counter);
    }, [count, ls_key]);

Choć props nie mogą się zmieniać, kompilator krzyczał na mnie ostrzeżenia, abym dodał ls_key do tablicy zależności, co też uczyniłem. Cały komponent powinien teraz wyglądać tak:

import React, { useState, useEffect } from 'react';

function CounterLS({ls_key}) {

    const [count, setCount] = useState(() => {
        const saved = localStorage.getItem(ls_key);
        const initialValue = JSON.parse(saved);
        return initialValue || 0;
      });
    
    useEffect( () => {
        localStorage.setItem(ls_key, count);
        console.log(localStorage.counter);
    }, [count, ls_key]);

    
    const increment = () => {
        setCount(count + 1);
    };

    const decrement = () => {
        setCount(count - 1); 
    };
    
    return (
        <div>
            <h1>Counter: {count}</h1>
            <button onClick={increment}>Zwiększ</button>
            <button onClick={decrement}>Zmniejsz</button>
        </div>
    );
}

export default CounterLS;

Nasze liczniki będą teraz posiadać odrębny klucz do zapisywania danych do localStorage. Co za tym idzie – możemy mieć dwa różne liczniki, po odświeżeniu każdy z nich będzie pamiętać swoją poprzednią wartość.

Hook useLocalStorage – piszemy własny hook dla Reacta!

Skoro wiemy, jak używać localStorage przy pomocy hooków useState i useEffect, to nie pozostaje nam nic innego, jak tylko napisać własny Reactowy hook useLocalStorage, który zrobi za nas całą robotę, raz utworzony będzie do użycia w dowolnym miejscu. To łatwiejsze, niż nam się wydaje.

Tworzymy w folderze components plik useLocalStorage.js. Dodajemy do niego, co następuje:

import { useState, useEffect } from "react";

function getStorageValue(key, defaultValue) {

  const saved = localStorage.getItem(key);
  const initial = JSON.parse(saved);
  return initial || defaultValue;
}

Mamy tutaj importy oraz funkcję pomocniczą, która pobiera wartość z localStorage o danym kluczu, a gdy wartości nie znajdzie, zwraca wartość domyślną, którą musimy przekazać. Teraz czas na główny hook useLocalStorage, który z tej funkcji pomocniczej skorzysta:

export const useLocalStorage = (key, defaultValue) => {
  const [value, setValue] = useState(() => {
    return getStorageValue(key, defaultValue);
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
};

Jeżeli dobrze się zastanowimy, to nie ma tu nic, co by mogło nas zaskoczyć. Tworzymy nowy stan, sprawdzamy, czy wartość o podanym kluczu istnieje – jeżeli tak, to ona jest domyślnie pierwszym stanem, jeżeli nie – wartość domyślna przez nas przekazana.

Następnie useEffect nasłuchuje na zmiany klucza bądź wartości i zapisuje element o odpowiednim kluczu z odpowiednią wartością. Ile razy ktoś użyje setValue (albo zmieni się klucz) tyle razy nowa wartość zostanie zapisana pod odpowiednim kluczem.

Zwracamy value oraz setValue, czyli działamy podobnie, do hooka useState. Używamy naszego useLocalStorage równie podobnie.

Wejdźmy do CounterLS, zaimportujmy nasz hook i skorzystajmy z niego:

import { useLocalStorage } from './useLocalStorage';
function CounterLS({ls_key}) {

    const [count, setCount] = useLocalStorage(ls_key, 0);
    
    const increment = () => {
        setCount(count + 1);
    };

    const decrement = () => {
        setCount(count - 1); 
    };
    
    return (
        <div>
            <h1>Counter: {count}</h1>
            <button onClick={increment}>Zwiększ</button>
            <button onClick={decrement}>Zmniejsz</button>
        </div>
    );
}

export default CounterLS;

Jak widać – kod uprościł nam się drastycznie. Podajemy tylko nazwę klucza oraz wartość domyślną, zaś uzyskujemy funkcje do odczytu i zmiany wartości jedną linijką, bez zawracania sobie głowy czymkolwiek.

Teraz jeszcze dokonajmy jakiejś zmiany w App.js i zrestartujmy (to jest włączmy, albo jeżeli nadal chodzi – wyłączmy i włączmy – naszą aplikację):

import React from 'react';
import CounterLS from './components/CounterLS';
function App() {
  
  return (
    <>
      <CounterLS ls_key={"key1"}/>
      <CounterLS ls_key={"key2"}/>
    </>
  );
}

export default App;

Jeżeli wszystko wykonaliśmy jak należy – nasze liczniki będą działać. Każdy ma osobny stan oraz osobny klucz w localStorage. Mało tego – mamy hook useLocalStorage, którego możemy używać z dowolnymi komponentami, bez skomplikowanej logiki, bez powtarzania tych samych fragmentów kodu.

Teraz możemy co najwyżej stworzyć folder hooks i tam nasz useLocalStorage umieścić (oczywiście wtedy importy nie będą działać aż nie podamy poprawnej ścieżki).

Zrobiliśmy dzisiaj naprawdę dużo. Taki hook możemy użyć np. w projekcie ToDo App, który prędzej czy później napiszemy sobie w bibliotece React.