Dette er del 2 av "Alt du trenger å vite om klient- og servertilstand". I forrige post så vi på hvordan klient- og servertilstand påvirker koden vi skriver.
I denne posten legger vi til en dimensjon: lokal- og global tilstand — altså hvor dataene er tilgjengelige i applikasjonen.
Denne bloggposten er del 2 av to bloggposter, om alt du trenger å vite om klient- og servertilstand i en klient-rendret React-applikasjon.
For del 1, se luke 3: https://www.bekk.christmas/post/2024/03/alt-du-trenger-a-vite-om-klient-og-servertilstand-del-1
Denne bloggposten er også tilgjengelig som video, spilt inn fra Bekks fagdag. Denne posten dekker fra og med 9:30 i videoen:
En klient-tilstand kan være lokal eller global. Og en server-tilstand kan være lokal eller global:
Så om vi går tilbake til eksempelet fra første post, hvilken del av tilstanden er lokal eller global?
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();
}, []);
Her er svaret at både tilstanden for email
og userCount
er lokal. Verken email
eller userCount
eksponeres videre ut, så tilstanden er tilgjengelig andre steder. Dette betyr at med denne koden, må userCount
fetches på nytt, selv om vi nettopp har gjort det, andre steder i applikasjonen.
Så hva er global klient-tilstand?
Et typisk eksempel er darkmode, som er tilgjengelig i hele applikasjonen. Du kan styre tilstanden med en useState, så eksponere verdien i en React Context:
const { isDarkMode, setIsDarkMode } = useContext(DarkModeContext);
Så hvordan kan vi gjøre servertilstand global?
Vi kunne ha kapsulert fetchingen av userCount
, med useEffect
og useState
, også i en context. Men da står vi igjen med problemet med alle hensynene vi må ta, som å håndtere race- conditions og retry-logikk.
Så hva gjør vi?
Løsningen er igjen TanStack Query — for denne gjør tilstanden også global. Når vi prøver å hente data flere steder, vil den bruke dataene som allerede eksisterer i cachen først:
export function ProfileForm() {
const [email, setEmail] = useState("");
const { data, error, isLoading } = useQuery({
// 👇 Sjekker cache først, så eventuelt hente ny data
queryKey: ["userCount"],
queryFn: fetchUserCount,
});
return (
);
}
Verktøy for tilstand
Det fins en hel drøss med verktøy for tilstand:
- useState
- useRef
- useReducer
- useContext
- URL-parameter
- Redux
- Zustand
- MobX
- Jotai
- TanStack Query
- useSWR
De fire første her kommer fra React ut av boksen, mens URL-parameter er nå tilgjengelig i alle nettsider. Resten er tredjepartsbiblioteker. Hvordan velger du, når du har så mange valg?
Det er nyttig å sette disse verktøyene i system, utifra hvilken tilstand de er beregnet for:
Det enkleste skillet er mellom klient- og servertilstand. Velg TanStack Query om du håndterer tilstand fra en ekstern kilde.
Årsaken til at det er så mange verktøy i klient-tilstands-bolken, tror jeg kommer av at vi har brukt verktøy for klienttilstand også for asynkron data. Men som vi har sett, fins det enklere verktøy for det.
For å velge klient-tilstandsverktøy, la oss undersøke et eksempel, selvfølgelig med enkel todo-kode.
Når du velger verktøy, er det fint å starte lokalt, med useState. Da beholder du isoleringen en komponent har, og kan unngå spaghettikode på tvers:
export function Todos() {
const [tasks, setTasks] = useState(initialTasks);
function handleAddTask(text) {
setTasks([
...tasks,
{
id: nextId++,
text: text,
done: false,
},
]);
}
return (
...
);
}
Om du har en komplisert tilstandshåndtering, kan det være nyttig å bytte fra useState
til useReducer
. useReducer er en funksjon React-teamet lånte fra Redux. Den gjør samme nytten ved å holde på lokal state, men den skiller mellom brukerhandlingene som trigger state-endring og implementasjonsdetaljene for hvordan staten skal endres. Dette er henholdsvis en “action” og en “reducer”:
export function Todos() {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
function handleAddTask(text) {
dispatch({
type: 'added', // 👈 Action, hvor du sender med detaljer
id: nextId++,
text: text,
});
}
return (
...
);
}
function tasksReducer(tasks, action) {
switch (action.type) {
case "added": { // 👈 Reducer, med implementasjonsdetaljer per action
return [
...tasks,
{
id: action.id,
text: action.text,
done: false,
},
];
}
case "deleted": {
return tasks.filter((t) => t.id !== action.id);
}
default: {
throw Error("Unknown action: " + action.type);
}
}
}
Ved å skille mellom hva du endrer og hvordan, kan du få mer leselig kode — men det er noe du bør gripe etter først når useState ikke er nok.
Vi kommer et stykke med lokal tilstand, og om vi trenger tilstanden i flere komponenter, kan vi prop-drille:
export function Todos() {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
return (
<OtherComponent tasks={tasks} />>
);
}
Men å drille i mer enn 3 lag kan gjøre vondt. Komponenter får props de ikke bryr seg om, og endringer kan kreve endringer i mange filer. Da bør du strekke deg etter global tilstand.
Et naturlig sted er å starte med Reacts innebygde global tilstandsverktøy, React Context:
export function Todos() {
const { tasks } = useContext(TasksContext);
...
}
export function OtherSetterComponent() {
const { setTasks } = useContext(TasksContext);
...
}
Her ser du to ulike komponenter som konsumerer en context for å hente ut henholdsvis en state og en setter. Ser du et problem med koden?
Komponenter som konsumerer en kontekst vil re-rendre når staten i konteksten endres. Så her vil komponenten som setter verdien også re-rendre når tilstanden endres, selv om den ikke bruker tilstanden i komponenten. Dette kan føre til flere unødvendige re-renders.
React Context bør bare brukes for state som ikke endrer seg så ofte og som ikke har dyre kalkulasjoner. Eksempler på dette er språk og theming. Du bør også dele opp kontekster, så re-renders skjer uavhengig av hverandre. Så istedenfor å ha én stor kontekst, deler du opp i ulike bruk, så nettopp språk og theming får hver sin kontekst.
Har du mer kompliserte behov, kan du se til andre verktøy. Zustand er et populært verktøy for global klient-tilstand, og har et enklere API enn Redux.
Først oppretter vi en store, altså vår globale tilstand:
import { create } from 'zustand'
const useTasksStore = create((set) => ({
// 👇 Tilstand vi kan hente ut
tasks: initialTasks,
// 👇 Setter-funksjon for å oppdatere tilstand
addTask: (newTask) => set((state) => ({ tasks: [...state.tasks, newTask] })),
}))
Så kan vi hente ut global tilstand fra komponentene våre:
export function Todos() {
// 👇 Abonnerer kun på tilstand
const tasks = useTasksStore((state) => state.tasks)
...
}
export function OtherSetterComponent() {
// 👇 Abonnerer kun på setter-funksjonen
const addTask = useTasksStore((state) => state.addTask)
...
}
Her vil komponentene bare re-rendre avhengig av verdien de lytter på, og du unngår unødvendige re-renders.
Hvordan velge blant alle disse verktøyene?
Det viktigste er å velge fra riktig gruppe av verktøy, som er avhengig av hvilken tilstand du har med å gjøre:
Henter eller oppdaterer du data som kommer fra serveren, bruk TanStack Query. Om du ikke håndterer servertilstand, start enkelt med en useState. Så legg til et verktøy for global tilstand når det gjør vondt å prop-drille.
Nå håper jeg du forstår forskjellene mellom tilstandstypene, så du kan velge riktig verktøy, og dermed skrive leselig kode med en høyere ytelse — som også gir brukeren en bedre opplevelse.