useSyncExternalStore er en React-hook du sannsynligvis aldri har brukt – i hvert fall ikke direkte. Men den ligger under panseret på mange verktøy, som TanStack Query, og ble introdusert i React 18 for å synkronisere ikke-React-kode med React. Selv om hooken har blitt omfavnet av bibliotek-utviklere, har den mye å lære oss andre "vanlige" utviklere om hvordan React fungerer. Og vi skal se på et case hvor også du kan dra nytte av denne hooken.
Hvordan reagerer React på ikke-React-kode?
Her har jeg lagd en enkel counter:
Koden bak er enkel, og kanskje du også klarer å se en feil med koden?
let count = 0;
export function Counter() {
function incrementer() {
count += 1;
}
return (
<button onClick={incrementer}>count is {count}</button>
);
}
Counteren fungerer ikke. Ja, verdien til count økes, men teksten i knappen vil ikke oppdateres i det du trykker. Du kan imidlertid se at count
økes med console.log
:
export function Counter() {
function incrementer() {
count += 1;
console.log(count); // Log: 1, 2, osv.
}
Årsaken til at UI-et ikke oppdateres, er at det ikke er brukt React state her, så React ikke får med seg endringer. Det er kun endring i React state som trigger en re-render i React.
Så i dette tilfellet, kunne vi ha løst problemet ved å ha count
i en useState
. Men iblant håndterer vi kode som ikke er en del av React, men som vi ønsker å følge med på. Dette kan være tredjepartsbiblioteker med state utenfor React eller nettleser-API-er som local storage.
Om React bare reagerer på endring i React state, hvordan kan vi sørge for at React får med seg endringer i ekstern tilstand?
Hvordan hjelper useSyncExternalStore oss?
Hooken useSyncExternalStore
er beregnet på nettopp dette. Den synkroniserer en ekstern tilstand, som count
-variabelen utenfor React-komponenten, med vår egen React kode.
Det ser slik ut:
const count = useSyncExternalStore(subscribe, getSnapshot);
Nå kommer count
fra denne hooken. Hooken tar imot to verdier. En subscribe
-funksjon, som holder rede på lyttere på verdien. Og getSnapshot
, som gir oss nåværende verdi.
getSnapshot er veldig enkel. Her har jeg omdøpt count
-en vi får utenfra til externalCount
:
// 👇 Initierer external store
let externalCount = 0;
function getSnapshot() {
// 👇 Returnerer nåværende verdi
return externalCount;
}
I subscribe
-funksjonen sier vi hvordan vi skal legge til lyttere på tilstanden, og hvordan vi skal fjerne lyttere:
let listeners: (() => void)[] = [];
function subscribe(listener: () => void) {
// 👇 Legg til lytter
listeners.push(listener);
// 👇 Cleanup-funksjon som fjerner lytter (f.eks. ved demount)
return () => {
listeners = listeners.filter((l) => l !== listener);
};
}
Hver lytter er en funksjon. Når vi kaller på den, vil den kalle på getSnapshot
, og returnere nåværende verdi. Så vi må oppdatere vår inkrementeringsfunksjon til å også si ifra til lytterne ved endring:
function notifyListeners() {
// 👇 Hver lytter henter verdi på nytt (kaller getSnapshot)
listeners.forEach((listener) => listener());
}
export function Counter() {
const count = useSyncExternalStore(subscribe, getSnapshot);
// 👇 Ved endring, også si ifra til lyttere
const incrementer = () => {
externalCount += 1;
notifyListeners();
};
return (
<button onClick={incrementer}>count is {count}</button>
);
}
Nå vil endringer i count
- variabalen, selv om den er utenfor en useState
, varsle alle lyttere om endring. Så nå vil vi kunne se at teksten i knappen inkrementeres når knappen trykkes.
Nå så vi på et noe kunstig eksempel med en enkel variabel utenfor en komponent. Men mønsteret går igjen når vi skal snakke med nettleser-API-et.
Eksempel med local storage
Før du ser på koden under, reflekter over hvordan du selv ville ha fulgt med på endringer på local storage.
Fått tenkt litt?
La oss igjen se på counter, men denne gangen hvordan du kan følge med på local storage med useSyncExternalStore
:
export function CounterSyncExternal() {
const count = useSyncExternalStore(subscribe, getSnapshot);
const increment = () => {
const newCount = count + 1;
localStorage.setItem("count", JSON.stringify(newCount));
};
return (
<button onClick={increment}>count is {count}</button>
);
}
Nå vil subscribe
- og getSnapshot
- funksjonene våre se annerledes ut.
Når vi nå henter nåværende verdi, vil vi sjekke local storage:
function getSnapshot() {
const savedCount = localStorage.getItem("count");
return savedCount ? Number(savedCount) : 0;
}
Og for å legge til en lytter, vil vi ikke nå holde styr på en lyttere selv, men heller legge til eventlistener på vinduet, som vil følge med på endringer i eventet “storage”:
function subscribe(listener: () => void) {
window.addEventListener("storage", listener);
return () => {
window.removeEventListener("storage", listener);
};
}
Legg merke til at vi ikke trenger å ha en egen funksjon notifyListeners
, for nå vil eventlytteren si ifra om endringer, via eventet storage
. Og vi har mata inn lytter-funksjonen der, som vil hente ny verdi om local storage endres.
Det er en liten ting vi må fikse før koden fungerer som forventet. Om vi nå trykker på counteren, vil ikke tilstanden oppdateres. Årsaken er at storage-eventer fra samme fane ikke registreres. En måte å løse dette på, er å manuelt trigge eventet:
const increment = () => {
const newCount = count + 1;
localStorage.setItem("count", JSON.stringify(newCount));
// 👇 Må manuelt trigge "storage"-eventet for å se endringen i samme fane
window.dispatchEvent(new StorageEvent("storage"));
};
Så alt i alt, blir koden slik:
function subscribe(listener: () => void) {
window.addEventListener("storage", listener);
return () => {
window.removeEventListener("storage", listener);
};
}
function getSnapshot() {
const savedCount = localStorage.getItem("count");
return savedCount ? Number(savedCount) : 0;
}
export function CounterSyncExternal() {
const count = useSyncExternalStore(subscribe, getSnapshot);
const increment = () => {
const newCount = count + 1;
localStorage.setItem("count", JSON.stringify(newCount));
window.dispatchEvent(new StorageEvent("storage"));
};
return (
<button onClick={increment}>count is {count}</button>
);
}
Så hva tenker du? Er dette veien å gå for å snakke med local storage?
Du kan jo sammenligne med hvordan dette kunne sett ut med useState
og useEffect
:
export function Counter() {
// 👇 Initier tilstand fra local storage
const [count, setCount] = useState(() => {
const savedCount = localStorage.getItem("count");
return savedCount ? Number(savedCount) : 0;
});
/*
👇 Når count endres, lagre verdien også i local storage.
Og trigg storage-event
*/
useEffect(() => {
localStorage.setItem("count", JSON.stringify(count));
window.dispatchEvent(new StorageEvent("storage"));
}, [count]);
useEffect(() => {
const handleStorageChange = () => {
const savedCount = localStorage.getItem("count");
setCount(Number(savedCount));
};
// 👇 Ved storage-eventer, oppdater count-tilstanden med lagret verdi
window.addEventListener("storage", handleStorageChange);
// 👇 Fjern lytter når komponenten demounter
return () => {
window.removeEventListener("storage", handleStorageChange);
};
}, []);
// 👇 Øk count med 1
const increment = () => {
setCount((prevCount) => prevCount + 1);
};
return (
<button onClick={increment}>count is {count}</button>
);
}
Koden over er nok mer kjent for deg hvert fall, selv om det er noen linjer ekstra kode.
Når bør du bruke useSyncExternalStore?
Du vil nok ikke bruke useSyncExternalStore
direkte i komponentene dine, men heller abstrahere logikken din til en egen hook. Da slipper du å definere subscribe
- og getSnapshot
-funksjonene om igjen, og kan enkelt få tilgang på verdien du bryr deg om:
export function CounterSyncExternal() {
/*
👇 Abstrahert tidligere logikk med useSyncExternalStore
til en hook
*/
const { count, increment } = useCounter();
return (
<button onClick={increment}>count is {count}</button>
);
}
function App() {
return (
<>
<CounterSyncExternal />
<CounterSyncExternal />
</>
)
En fordel med useSyncExternalStore, at du nå har en hook som lytter på samme verdi, og som du kan bruke flere steder. count
blir dermed en global tilstand.
Du får ikke det samme når du bruker useState
. useState
skaper en lokal, isolert tilstand. Så selv om du har lyttere på samme event, kan oppdateringen skje på forskjellige tidspunkter, og dermed kan du se en liten forsinkelse når count
oppdateres. For å fikse syncen, må du løfte state opp, bruke context eller bruke et annet annet globalt tilstandsbibliotek.
En annen grunn til å bruke useSyncExternalStore
, er om du serverrendrer koden. For hooken har nemlig en tredje, valgfri parameter: getServerSnapshot
:
function getServerSnapshot() {
return 0; // 👈 Initiererer counter med 0, for å unngå hydrerings-mismatch
}
// ...
// 👇 useSyncExternalStore har en tredje, valgfri parameter
const count = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
getServerSnapshot
bruker du for å sette verdien som er den initielle verdien for server og klient. Dette gjør du for å unngå hydreringsfeil. Med useSyncExternalStore kan du altså slippe å skrive implisitte sjekker av om du er på serveren, som å først sjekke om window
eksisterer.
useSyncExternalStore
er en nyttig hook for å snakke med nettleser-API eller å unngå hydreringsfeil.
Når det kommer til annen state, som fra biblioteker, trenger du nok ikke å bruke hooken. Tredjeparts state-biblioteker som Zustand og React Query har tilstand som trigger re-rendringer i React allerede, og flere har tatt i bruk useSyncExternalStore
allerede (se for eksempel kildekoden til Zustand eller TanStack Query). Faktisk var årsaken til at TanStack Query i ny versjon krevde React 18, at de gikk fra en hjemmelagd versjon til å ta i bruk useSyncExternalStore direkte.
Så neste gang du skal snakke med nettleser-API, enten det er en lytter på vindu-størrelse, scrolling eller nettopp local storage, test ut useSyncExternalStore.