En dybdegående gennemgang af Reacts useSyncExternalStore-hook til synkronisering af eksterne datastores, inklusive implementeringsstrategier, performanceovervejelser og avancerede use cases.
React useSyncExternalStore: Mestring af synkronisering med eksterne stores
I moderne React-applikationer er effektiv state management afgørende. Selvom React tilbyder indbyggede løsninger til state management som useState og useReducer, kræver integration med eksterne datakilder eller tredjeparts state management-biblioteker en mere sofistikeret tilgang. Det er her, useSyncExternalStore kommer ind i billedet.
Hvad er useSyncExternalStore?
useSyncExternalStore er et React-hook introduceret i React 18, som giver dig mulighed for at abonnere på og læse fra eksterne datakilder på en måde, der er kompatibel med concurrent rendering. Dette er især vigtigt, når man håndterer data, der ikke direkte styres af React, såsom:
- Tredjeparts state management-biblioteker: Redux, Zustand, Jotai, osv.
- Browser-API'er:
localStorage,IndexedDB, osv. - Eksterne datakilder: Server-sent events, WebSockets, osv.
Før useSyncExternalStore kunne synkronisering af eksterne stores føre til 'tearing' og inkonsistens, især med Reacts concurrent rendering-funktioner. Dette hook løser disse problemer ved at tilbyde en standardiseret og performant måde at forbinde eksterne data til dine React-komponenter.
Hvorfor bruge useSyncExternalStore? Fordele og gevinster
Brug af useSyncExternalStore giver flere vigtige fordele:
- Sikkerhed i concurrent mode: Sikrer, at din komponent altid viser en konsistent visning af den eksterne store, selv under concurrent renders. Dette forhindrer 'tearing'-problemer, hvor dele af din UI kan vise inkonsistente data.
- Performance: Optimeret for ydeevne, hvilket minimerer unødvendige re-renders. Det udnytter Reacts interne mekanismer til effektivt at abonnere på ændringer og opdatere komponenten kun, når det er nødvendigt.
- Standardiseret API: Giver en konsistent og forudsigelig API til interaktion med eksterne stores, uanset den underliggende implementering.
- Mindre boilerplate-kode: Forenkler processen med at forbinde til eksterne stores, hvilket reducerer mængden af brugerdefineret kode, du skal skrive.
- Kompatibilitet: Fungerer problemfrit med et bredt udvalg af eksterne datakilder og state management-biblioteker.
Hvordan useSyncExternalStore virker: En dybdegående gennemgang
useSyncExternalStore-hooket tager tre argumenter:
subscribe(callback: () => void): () => void: En funktion, der registrerer et callback, som skal underrettes, når den eksterne store ændres. Den skal returnere en funktion til at afmelde abonnementet. Det er sådan, React lærer, hvornår storen har nye data.getSnapshot(): T: En funktion, der returnerer et øjebliksbillede (snapshot) af dataene fra den eksterne store. Dette snapshot skal være en simpel, uforanderlig værdi, som React kan bruge til at afgøre, om dataene har ændret sig.getServerSnapshot?(): T(Valgfri): En funktion, der returnerer det indledende snapshot af dataene på serveren. Dette bruges til server-side rendering (SSR) for at sikre konsistens mellem server og klient. Hvis den ikke angives, vil React brugegetSnapshot()under server-rendering, hvilket måske ikke er ideelt i alle scenarier.
Her er en oversigt over, hvordan disse argumenter arbejder sammen:
- Når komponenten mounter, kalder
useSyncExternalStoresubscribe-funktionen for at registrere et callback. - Når den eksterne store ændres, påkalder den det callback, der er registreret via
subscribe. - Callback'et fortæller React, at komponenten skal re-rendere.
- Under renderingen kalder
useSyncExternalStoregetSnapshotfor at hente de seneste data fra den eksterne store. - React sammenligner det aktuelle snapshot med det forrige. Hvis de er forskellige, opdateres komponenten med de nye data.
- Når komponenten unmountes, kaldes afmeldingsfunktionen, der returneres af
subscribe, for at forhindre hukommelseslækager.
Grundlæggende implementeringseksempel: Integration med localStorage
Lad os illustrere, hvordan man bruger useSyncExternalStore med et simpelt eksempel: at læse og skrive en værdi til localStorage.
import { useSyncExternalStore } from 'react';
function getLocalStorageItem(key: string): string | null {
try {
return localStorage.getItem(key);
} catch (error) {
console.error("Fejl ved adgang til localStorage:", error);
return null; // Håndter potentielle fejl, som f.eks. at `localStorage` ikke er tilgængelig.
}
}
function useLocalStorage(key: string): [string | null, (value: string) => void] {
const subscribe = (callback: () => void) => {
window.addEventListener('storage', callback);
return () => window.removeEventListener('storage', callback);
};
const getSnapshot = () => getLocalStorageItem(key);
const serverSnapshot = () => null; // Eller en standardværdi, hvis det er relevant for dit SSR-setup
const value = useSyncExternalStore(subscribe, getSnapshot, serverSnapshot);
const setValue = (newValue: string) => {
try {
localStorage.setItem(key, newValue);
// Udløs en storage-event på det aktuelle vindue for at aktivere opdateringer i andre faneblade.
window.dispatchEvent(new StorageEvent('storage', {
key: key,
newValue: newValue,
storageArea: localStorage,
} as StorageEventInit));
} catch (error) {
console.error("Fejl ved indstilling af localStorage:", error);
}
};
return [value, setValue];
}
function MyComponent() {
const [name, setName] = useLocalStorage('name');
return (
<div>
<p>Hej, {name || 'Verden'}</p>
<input
type="text"
value={name || ''}
onChange={(e) => setName(e.target.value)}
/>
</div>
);
}
export default MyComponent;
Forklaring:
getLocalStorageItem: En hjælpefunktion til sikkert at hente værdien fralocalStorageog håndtere potentielle fejl.useLocalStorage: Et custom hook, der indkapsler logikken for at interagere medlocalStorageved hjælp afuseSyncExternalStore.subscribe: Lytter efter'storage'-eventet, som udløses, nårlocalStorageændres i et andet faneblad eller vindue. Kritisk er det, at vi udløser en storage-event efter at have sat en ny værdi for korrekt at aktivere opdateringer i det *samme* vindue.getSnapshot: Returnerer den aktuelle værdi fralocalStorage.serverSnapshot: Returnerernull(eller en standardværdi) for server-side rendering.setValue: Opdaterer værdien ilocalStorageog udløser en storage-event for at signalere til andre faneblade.MyComponent: En simpel komponent, der brugeruseLocalStorage-hooket til at vise og opdatere et navn.
Vigtige overvejelser for localStorage:
- Fejlhåndtering: Omgiv altid adgang til
localStoragemedtry...catch-blokke for at håndtere potentielle fejl, f.eks. nårlocalStorageer deaktiveret eller utilgængelig (f.eks. i privat browsing-tilstand). - Storage Events:
'storage'-eventet udløses kun, nårlocalStorageændres i et *andet* faneblad eller vindue, ikke i det samme vindue. Derfor udløser vi manuelt en nyStorageEventefter at have sat en værdi. - Dataserialisering:
localStoragegemmer kun strenge. Du skal muligvis serialisere og deserialisere komplekse datastrukturer ved hjælp afJSON.stringifyogJSON.parse. - Sikkerhed: Vær opmærksom på de data, du gemmer i
localStorage, da de er tilgængelige for JavaScript-kode på samme domæne. Følsomme oplysninger bør ikke gemmes ilocalStorage.
Avancerede use cases og eksempler
1. Integration med Zustand (eller et andet state management-bibliotek)
Integration af useSyncExternalStore med et globalt state management-bibliotek som Zustand er en almindelig use case. Her er et eksempel:
import { useSyncExternalStore } from 'react';
import { create } from 'zustand';
interface BearState {
bears: number
increase: (by: number) => void
}
const useStore = create<BearState>((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by }))
}))
function BearCounter() {
const bears = useSyncExternalStore(
useStore.subscribe,
useStore.getState,
() => ({ bears: 0, increase: () => {} }) // Server snapshot, angiv en standardtilstand
).bears
return <h1>{bears} bjørne heromkring!</h1>
}
function Controls() {
const increase = useStore(state => state.increase)
return (<button onClick={() => increase(1)}>en bjørn</button>)
}
export { BearCounter, Controls }
Forklaring:
- Vi bruger Zustand til global state management.
useStore.subscribe: Denne funktion abonnerer på Zustand-storen og vil udløse re-renders, når storens state ændres.useStore.getState: Denne funktion returnerer den aktuelle tilstand af Zustand-storen.- Den tredje parameter angiver en standardtilstand for server-side rendering (SSR), hvilket sikrer, at komponenten renderes korrekt på serveren, før client-side JavaScript tager over.
- Komponenten henter antallet af bjørne ved hjælp af
useSyncExternalStoreog renderer det. Controls-komponenten viser, hvordan man bruger en Zustand-setter.
2. Integration med Server-Sent Events (SSE)
useSyncExternalStore kan bruges til effektivt at opdatere komponenter baseret på realtidsdata fra en server ved hjælp af Server-Sent Events (SSE).
import { useSyncExternalStore, useState, useEffect, useCallback } from 'react';
function useSSE(url: string) {
const [data, setData] = useState(null);
const [eventSource, setEventSource] = useState(null);
useEffect(() => {
const newEventSource = new EventSource(url);
setEventSource(newEventSource);
newEventSource.onmessage = (event) => {
try {
const parsedData = JSON.parse(event.data);
setData(parsedData);
} catch (error) {
console.error("Fejl ved parsing af SSE-data:", error);
}
};
newEventSource.onerror = (error) => {
console.error("SSE-fejl:", error);
};
return () => {
newEventSource.close();
setEventSource(null);
};
}, [url]);
const subscribe = useCallback((callback: () => void) => {
if (eventSource) {
eventSource.addEventListener('message', callback);
}
return () => {
if (eventSource) {
eventSource.removeEventListener('message', callback);
}
};
}, [eventSource]);
const getSnapshot = useCallback(() => data, [data]);
const serverSnapshot = useCallback(() => null, []);
const value = useSyncExternalStore(subscribe, getSnapshot, serverSnapshot);
return value;
}
function RealTimeDataComponent() {
const realTimeData = useSSE('/api/sse'); // Erstat med dit SSE-endpoint
if (!realTimeData) {
return <p>Indlæser...</p>;
}
return <div><p>Realtidsdata: {JSON.stringify(realTimeData)}</p></div>;
}
export default RealTimeDataComponent;
Forklaring:
useSSE: Et custom hook, der opretter en SSE-forbindelse til en given URL.subscribe: Tilføjer en event listener tilEventSource-objektet for at blive underrettet om nye beskeder fra serveren. Det brugeruseCallbackfor at sikre, at callback-funktionen ikke genskabes ved hver render.getSnapshot: Returnerer de senest modtagne data fra SSE-strømmen.serverSnapshot: Returnerernullfor server-side rendering.RealTimeDataComponent: En komponent, der brugeruseSSE-hooket til at vise realtidsdata.
3. Integration med IndexedDB
Synkroniser React-komponenter med data gemt i IndexedDB ved hjælp af useSyncExternalStore.
import { useSyncExternalStore, useState, useEffect, useCallback } from 'react';
interface IDBData {
id: number;
name: string;
}
async function getAllData(): Promise {
return new Promise((resolve, reject) => {
const request = indexedDB.open('myDataBase', 1); // Erstat med dit databasenavn og version
request.onerror = (event) => {
console.error("Fejl ved åbning af IndexedDB:", event);
reject(event);
};
request.onsuccess = (event) => {
const db = (event.target as IDBRequest).result as IDBDatabase;
const transaction = db.transaction(['myDataStore'], 'readonly'); // Erstat med navnet på din store
const objectStore = transaction.objectStore('myDataStore');
const getAllRequest = objectStore.getAll();
getAllRequest.onsuccess = (event) => {
const data = (event.target as IDBRequest).result as IDBData[];
resolve(data);
};
getAllRequest.onerror = (event) => {
console.error("Fejl ved IndexedDB getAll:", event);
reject(event);
};
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBRequest).result as IDBDatabase;
db.createObjectStore('myDataStore', { keyPath: 'id' });
};
});
}
function useIndexedDBData(): IDBData[] | null {
const [data, setData] = useState(null);
const [dbInitialized, setDbInitialized] = useState(false);
useEffect(() => {
const initDB = async () => {
try{
await getAllData();
setDbInitialized(true);
} catch (e) {
console.error("Initialisering af IndexedDB mislykkedes", e);
}
}
initDB();
}, []);
const subscribe = useCallback((callback: () => void) => {
// Debounce callback'et for at forhindre for mange re-renders.
let timeoutId: NodeJS.Timeout;
const debouncedCallback = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(callback, 50); // Juster debounce-forsinkelsen efter behov
};
const handleVisibilityChange = () => {
// Hent data igen, når fanebladet bliver synligt
if (document.visibilityState === 'visible') {
debouncedCallback();
}
};
window.addEventListener('focus', debouncedCallback);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('focus', debouncedCallback);
document.removeEventListener('visibilitychange', handleVisibilityChange);
clearTimeout(timeoutId);
};
}, []);
const getSnapshot = useCallback(() => {
// Hent de seneste data fra IndexedDB, hver gang getSnapshot kaldes
getAllData().then(newData => setData(newData));
return data;
}, [data]);
const serverSnapshot = useCallback(() => null, []);
return useSyncExternalStore(subscribe, getSnapshot, serverSnapshot);
}
function IndexedDBComponent() {
const data = useIndexedDBData();
if (!data) {
return <p>Indlæser data fra IndexedDB...</p>;
}
return (
<div>
<h2>Data fra IndexedDB:</h2>
<ul>
{data.map((item) => (
<li key={item.id}>{item.name} (ID: {item.id})</li>
))}
</ul>
</div>
);
}
export default IndexedDBComponent;
Forklaring:
getAllData: En asynkron funktion, der henter alle data fra IndexedDB-storen.useIndexedDBData: Et custom hook, der brugeruseSyncExternalStoretil at abonnere på ændringer i IndexedDB.subscribe: Opsætter lyttere for synligheds- og fokusændringer for at opdatere data fra IndexedDB og bruger en debounce-funktion for at undgå for mange opdateringer.getSnapshot: Henter det aktuelle snapshot ved at kalde `getAllData()` og returnerer derefter `data` fra state.serverSnapshot: Returnerernullfor server-side rendering.IndexedDBComponent: En komponent, der viser data fra IndexedDB.
Vigtige overvejelser for IndexedDB:
- Asynkrone operationer: Interaktioner med IndexedDB er asynkrone, så du skal håndtere den asynkrone natur af datahentning og opdateringer omhyggeligt.
- Fejlhåndtering: Implementer robust fejlhåndtering for at håndtere potentielle problemer med databaseadgang, såsom database ikke fundet eller tilladelsesfejl.
- Databaseversionering: Håndter databaseversioner omhyggeligt ved hjælp af
onupgradeneeded-eventet for at sikre datakompatibilitet, efterhånden som din applikation udvikler sig. - Performance: IndexedDB-operationer kan være relativt langsomme, især for store datasæt. Optimer forespørgsler og indeksering for at forbedre ydeevnen.
Performanceovervejelser
Selvom useSyncExternalStore er optimeret for ydeevne, er der stadig nogle overvejelser, man skal have i tankerne:
- Minimer ændringer i snapshots: Sørg for, at
getSnapshot-funktionen kun returnerer et nyt snapshot, når dataene rent faktisk har ændret sig. Undgå at oprette nye objekter eller arrays unødvendigt. Overvej at bruge memoization-teknikker til at optimere oprettelsen af snapshots. - Batch-opdateringer: Hvis det er muligt, så batch opdateringer til den eksterne store for at reducere antallet af re-renders. Hvis du f.eks. opdaterer flere egenskaber i storen, så prøv at opdatere dem alle i en enkelt transaktion.
- Debouncing/Throttling: Hvis den eksterne store ændrer sig hyppigt, kan du overveje at debounce eller throttle opdateringerne til React-komponenten. Dette kan forhindre for mange re-renders og forbedre ydeevnen. Dette er især nyttigt med volatile stores som f.eks. ændringer i browservinduets størrelse.
- Overfladisk sammenligning (Shallow Comparison): Sørg for at returnere primitive værdier eller uforanderlige objekter i
getSnapshot, så React hurtigt kan afgøre, om dataene har ændret sig ved hjælp af en overfladisk sammenligning. - Betingede opdateringer: I tilfælde, hvor den eksterne store ændrer sig hyppigt, men din komponent kun behøver at reagere på bestemte ændringer, kan du overveje at implementere betingede opdateringer inden i `subscribe`-funktionen for at undgå unødvendige re-renders.
Almindelige faldgruber og fejlfinding
- Tearing-problemer: Hvis du stadig oplever tearing-problemer efter at have brugt
useSyncExternalStore, skal du dobbelttjekke, at dingetSnapshot-funktion returnerer en konsistent visning af dataene, og atsubscribe-funktionen korrekt underretter React om ændringer. Sørg for, at du ikke muterer dataene direkte igetSnapshot-funktionen. - Uendelige loops: Der kan opstå et uendeligt loop, hvis
getSnapshot-funktionen altid returnerer en ny værdi, selv når dataene ikke har ændret sig. Dette kan ske, hvis du opretter nye objekter eller arrays unødvendigt. Sørg for, at du returnerer den samme værdi, hvis dataene ikke har ændret sig. - Manglende server-side rendering: Hvis du bruger server-side rendering, skal du sørge for at angive en
getServerSnapshot-funktion for at sikre, at komponenten renderes korrekt på serveren. Denne funktion skal returnere den indledende tilstand af den eksterne store. - Forkert afmelding: Sørg altid for, at du korrekt afmelder abonnementet fra den eksterne store i den funktion, der returneres af
subscribe. Hvis du ikke gør det, kan det føre til hukommelseslækager. - Forkert brug med Concurrent Mode: Sørg for, at din eksterne store er kompatibel med Concurrent Mode. Undgå at foretage mutationer i den eksterne store, mens React renderer. Mutationer skal være synkrone og forudsigelige.
Konklusion
useSyncExternalStore er et kraftfuldt værktøj til at synkronisere React-komponenter med eksterne datastores. Ved at forstå, hvordan det virker, og ved at følge bedste praksis, kan du sikre, at dine komponenter viser konsistente og opdaterede data, selv i komplekse concurrent rendering-scenarier. Dette hook forenkler integrationen med forskellige datakilder, fra tredjeparts state management-biblioteker til browser-API'er og realtids-datastrømme, hvilket fører til mere robuste og performante React-applikationer. Husk altid at håndtere potentielle fejl, optimere ydeevnen og omhyggeligt administrere abonnementer for at undgå almindelige faldgruber.