Tilstandshåndtering i frontend er utfordrende. Når du skal håndtere alt fra skjemafelter til darkmode og innlogging og API-data, kan koden bli kaotisk.
Jeg tror mye av grunnen til kaos, er fordi vi har behandlet flere typer tilstand som om de er det samme. Det å kunne skille tilstandstyper fra hverandre kan la deg droppe unødvendig kode, gi bedre brukeropplevelse og høyere ytelse.
Denne bloggposten er del 1 av 2, om alt du trenger å vite om klient- og servertilstand i en klient-rendret React-applikasjon.
Del 2 kommer i rute 6.
Denne bloggposten er også tilgjengelig som video, spilt inn fra Bekks fagdag. Denne posten dekker frem til 9:30 i videoen:
Hva er forskjellen på klient- og servertilstand?
La oss først starte med skillet mellom klient- og servertilstand:
Klienttilstand er tilstand som kommer fra klienten. Et eksempel er tekst i et skjemafelt eller innstillingen for darkmode. Klient-tilstanden er synkron, altså den endres umiddelbart. Den er også flyktig, og kan kun endres av en bruker. Alle disse tingene gjør klient-tilstand enkel å jobbe med.
Servertilstand er data som kommer fra en ekstern kilde, som en server eller et API. Et eksempel er brukerens profildata. Siden dataene kommer fra noe eksternt, tar det tid for oss å hente data eller å oppdatere data - altså er servertilstanden asynkron. Servertilstanden kan endres av flere, og er også lagret over tid. At servertilstanden er asynkron og at det er flere som endrer, gjør at server-tilstanden krever flere hensyn.
Enkelt eksempel
La oss si vi lager et enkelt skjema hvor en person kan opprette en bruker med en epost:
Se på følgende kode. Tilstanden for email
brukes for å holde rede på tilstand i et skjemafelt, mens userCount
brukes for å holde rede på antall opprettede brukere.
Hva her er klient- tilstand og hva er servertilstand?
export function ProfileForm() {
const [email, setEmail] = useState("");
const [userCount, setUserCount] = useState(null);
useEffect(() => {
const fetchUserCount = async () => {
const response = await fetch("https://api.example.com/user-count");
const data = await response.json();
setUserCount(data.count);
};
fetchUserCount();
}, []);
return (
...
<label>
E-post:
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</label>
...
)
Her er tilstanden for email
klient-tilstand, ettersom vi bare henter den fra brukerinput. Mens userCount
er server-tilstand, ettersom vi henter den fra en ekstern kilde, bortefra klienten.
Tenk på hva du trenger å gjøre når du henter data fra et API. Syns du det mangler noe i min kode?
- Siden dataene er asynkron, så tar det litt tid. Så vi bør legge til loading-state.
- Og siden vi gjør en fetch, kan den feile, så vi bør legge til error-state.
- Og siden fetchen kan feile, skal vi ikke gi opp av den grunn. Så vi bør legge til retry-logikk.
- Og om vi navigerer bort før kallet er ferdig, skal vi ikke overbelaste serveren med et unødvendig kall vi ikke bruker. Så vi bør legge til en AbortController.
Etter å ha hensyntatt dette, blir koden ganske omfattende! Bare skum over og se:
export function ProfileForm() {
const [email, setEmail] = useState("");
const [userCount, setUserCount] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
const fetchUserCount = async (retries = 3, delay = 1000) => {
setLoading(true);
try {
const response = await fetch("https://api.example.com/user-count", {
signal: controller.signal,
});
const data = await response.json();
setUserCount(data.count);
} catch (e) {
if (retries > 0) {
setTimeout(() => {
fetchUserCount(retries - 1, delay * 2);
}, delay);
} else {
setError("En feil skjedde");
}
} finally {
setLoading(false);
}
};
return () => {
controller.abort();
};
fetchUserCount();
}, []);
return (
...
);
}
Servertilstand krever altså en del hensyn, og dette er uten at vi en gang har nevnt caching.
Du kan altså klare deg med useState og useEffect, men som vi ser, blir det veldig komplisert. Løsningen er å bruke et verktøy beregnet for servertilstand. Som TanStack Query. Da blir koden betydelig forenklet:
async function fetchUserCount() {
const response = await fetch('https://api.example.com/user-count');
if (!response.ok) {
throw new Error('Failed to fetch user count');
}
return response.json();
}
export function ProfileForm() {
const [email, setEmail] = useState("");
const { data, error, isLoading } = useQuery({
queryKey: ["userCount"],
queryFn: fetchUserCount,
});
return (
...
);
}
Vi mater inn fetch-funksjonen vår inn i useQuery, så gir den oss en rekke funksjonalitet ut av boksen, også håndterer den tilstand for data, error og loading.
En betydelig fordel er at dataene lagres i en cache, her på nøkkelen userCount
. Dette gjør at om vi prøver å hente de samme dataene et annet sted, trenger vi ikke å gjøre et nytt nettverkskall.
Synkronisering med serveren
Siden andre personer også endrer på servertilstanden, kan vi risikere å hente data på ett tidspunkt, så 5 sekunder senere, har noen andre oppdatert dataene på serveren, så våre data blir utdatert.
useQuery kommer med en standard på 5 minutter for å markere data som gammelt. Da vil nye data bli hentet. Dette kan være fint i mange tilfeller, men du kan også overstyre det med staleTime-innstillingen:
export function ProfileForm() {
const { data, error, isLoading } = useQuery({
queryKey: ["userCount"],
queryFn: fetchUserCount,
// 👇 Data blir markert som gammelt etter 30s
staleTime: 1000 * 30,
});
return (
);
}
Det er ikke alltid du bryr deg om å hente nye data. Som hvis det ikke fins en maksgrense på antall brukere, så trenger du ikke nødvendigvis å ha de ferskeste dataene for det. Da kan du sette staleTime til Infinity, så dataene aldri blir markert som gamle.
staleTime kan hjelpe oss å holde data når andre endrer på data, men ofte endrer vi data selv, som når vi sender inn et skjema og oppretter en bruker. Da ønsker vi ofte også å vise endringen til brukeren, så vi må hente nye data. Da kan vi bruke useQuery sin andre halvdel, useMutation, for å håndtere muteringen. Så idet muteringen går gjennom, invaliderer vi cachen med den aktuelle nøkkelen:
export function ProfileForm() {
const queryClient = useQueryClient();
const [email, setEmail] = useState("");
const { data, error, isLoading } = useQuery({
queryKey: ["userCount"],
queryFn: fetchUserCount,
});
const mutation = useMutation({
// 👇 Sender vår postFunksjon til useMutation
mutationFn: createUser,
onSuccess: () => {
queryClient.invalidateQueries({
// 👇 Invaliderer cachen
queryKey: ["userCount"]
});
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
// 👇 mutation gir en rekke verdier, inkludert muteringsfunksjonen
mutation.mutate(email);
};
return (
);
}
Mønsteret over er veldig vanlig. Det er 20% av det TanStack Query tilbyr, men du kommer 80% på veien. Du henter altså data, og. når du muterer de samme dataene, invaliderer du også cachen, for å få ferske data.
Optimistiske oppdateringer
Servertilstanden vi har i klienten er et øyeblikksbilde. Dette øyeblikksbildet ønsker vi ofte å holde oppdatert med serveren. Siden servertilstanden er asynkron, kan det ta tid for oss å få den oppdateringen, noe som kan gi en dårlig brukeropplevelse. Ved å bruke mønsteret optimistisk oppdatering, kan du få oppdateringen til å oppleves synkron. Da antar du at oppdateringen går bra, noe det jo ofte gjør.
Jeg har tidligere skrevet om dette her: https://blogg.bekk.no/optimistiske-oppdateringer-i-tanstack-query-da70ceedf4a4
Avsluttende ord
I denne posten har vi sett på hvordan klient- og servertilstand påvirker koden vi lager. Forskjellen mellom klient- og server-tilstand er hvor dataene kommer fra. I del 2 av denne posten, som kommer i luke 6, vil du se på en annen dimensjon, nemlig hvor dataene er tilgjengelige i applikasjonen — altså om tilstanden er lokalt eller globalt tilgjengelig.