Kontakt os


Asynkron logik hører ikke hjemme i en Redux-reducer. Reducere er rene funktioner, så alle API-kald, timere og abonnementer skal placeres et andet sted, og i Redux er dette sted middleware. Redux Thunk er det naturlige udgangspunkt. For de fleste teams er det også slutpunktet.
Det følgende er baseret på en omfattende React- og Node.js-applikation, som vi migrerede fra et ad hoc-lag til tilstandshåndtering. Første halvdel dækker beslutningsprocessen og omkostningerne forbundet hermed; fra "Sådan implementeres Redux Thunk" og frem er der tale om selve implementeringen. Hvis du kun har brug for beslutningsgrundlaget, kan du stoppe ved udvælgelseskriterierne.
Da vi skulle vælge en stack til dette projekt, var React det oplagte valg til frontenden, eftersom arbejdet involverede en stor mængde visuel præsentation og 3D-modellering.
Hvad vi ikke fik defineret, var en strategi for state management. Efterhånden som projektet udviklede sig, blev det et problem, så teamet flyttede al logik for state management over i en klasse implementeret som en Singleton: en enkelt delt instans, som alle komponenter læser fra og skriver til. Det håndterede lagring og hændelser relateret til state nogenlunde.
Nogenlunde, i et stykke tid. Til sidst blev det utilstrækkeligt, og vi lagde en plan for at finde et bedre alternativ. Det kom i form af Redux, hjulpet på vej af introduktionen af Redux Toolkit, tidligere kendt som Redux Starter Kit.
Singleton-mønsteret var aldrig planen. Ingen vælger det frivilligt. Det er den vigtigste pointe i denne artikel, for ustruktureret state er sjældent et teknisk problem i første omgang. Det viser sig som leveringsomkostninger.
Forestil dig en whiteboardtavle på et fælleskontor, som alle kan skrive på, uden navne eller tidsstempler. Tavlen fortæller dig altid, hvad den aktuelle status er. Den fortæller dig aldrig, hvem der ændrede den, hvornår eller hvorfor, så hvis du vil finde ud af, hvordan en værdi er havnet der, er du nødt til at spørge alle, der er gået forbi.

Det var vores Singleton, og omkostningerne kunne måles tre steder. Debugging tog længere tid, fordi sporing af én forkert værdi betød, at man manuelt skulle læse alle steder, hvor der blev skrevet til den. [Forfatter bekræfter: hvor mange udviklertimer pr. sprint teamet brugte på denne sporing før migreringen.] Onboarding tog længere tid, fordi den delte instans ikke havde nogen kontrakt, som en ny udvikler kunne læse, så den eneste måde at lære statemodellen at kende på var ved at læse hver eneste komponent, der rørte ved den. [Forfatter bekræfter: hvor lang tid det tog en ny udvikler at blive produktiv i statelaget før kontra efter.] Og hver ny funktion medførte en lille ekstraomkostning, fordi nogen manuelt skulle finde ud af, hvilke dele af det delte objekt der var sikre at røre ved.
Redux gjorde ikke applikationen hurtigere. Det gjorde statelaget læsbart. Handlinger navngives, ændringer logges, og historikken kan inspiceres under test, så den tid, vi før spildte på at finde ud af, "hvor kom denne værdi fra", forsvandt stort set. Hvis du overvejer en migrering som denne, er det det afkast, du skal måle: ikke runtime-performance, men de timer dit team i øjeblikket bruger på manuelt at rekonstruere state. Det er det samme afkast, vi kigger efter, når vi foretager en kodeaudit af en eksisterende kodebase.
Redux er en state-container til JavaScript-applikationer, der lader dig omgå Reacts naturlige ensrettede dataflow. Den indeholder en enkelt kilde til sandhed, som du kan tilgå overalt i din applikation uden at skulle sende state videre som props til andre komponenter.
Den lader dig også ændre denne state gennem foruddefinerede handlinger, samtidig med at den gemmer en historik over disse handlinger og ændringer, som kan gennemgås under test. Navngivne forfattere, tidsstemplede. Whiteboardet, endelig med en logbog tilknyttet.
Da Facebook præsenterede React for verden, stod det allerede klart for dem, hvordan props kunne blive en stor hindring ved et vist kompleksitetsniveau. For at afbøde dette introducerede de et koncept sideløbende med React kaldet Flux, som beskriver, hvordan en store bør fungere i samspil med React. Redux, som vi kender det i dag, stammer fra et proof of concept, som Dan Abramov byggede, mens han eksperimenterede med Flux-principper til React Europe.
Redux bruges i store applikationer, hvor en interaktion med én komponent kan sprede ændringer til hele siden. I stedet for at oprette callbacks øverst i applikationen og sende dem ned gennem lagene, konsulterer du blot din store.
I mit projekt gav det mening, fordi applikationen var vokset til et punkt, hvor state-relaterede props blev sendt ned gennem adskillige lag af komponenter. Det gjorde koden svær at læse og endnu sværere at debugge. (Den samme logik gør sig gældende for mobil. Hvis du er ved at integrere Redux i React Native, gennemgår vi opsætningen i vores guide til React Native med Redux.)
Redux gjorde en øjeblikkelig forskel, da vi begyndte at migrere gammel kode til det. Koden blev lettere at følge, teamet lærte det hurtigt, og antallet af props, der flød rundt i applikationen, faldt drastisk.
Men det var ikke perfekt. Selve reducere-konceptets natur skaber et problem, så snart man forsøger at inkludere hentning af information i dem, og det er noget, Redux-fællesskabet har arbejdet med i meget lang tid.
Reducere er i teorien rene funktioner, ifølge dokumentationen selv.
Givet de samme argumenter bør de beregne den næste tilstand og returnere den. Ingen overraskelser. Ingen bivirkninger. Ingen API-kald. Ingen mutationer. Bare en beregning.
Så hvor skal man placere asynkrone kald i Redux?
Handlinger bør være det umiddelbare svar, men den grundlæggende implementering af en handling er ikke andet end et almindeligt JavaScript-objekt, som du bruger til at sende information til din store. Derfor fandt fællesskabet på middlewares: funktioner, der ligger mellem afsendelsen af en handling og reducerens modtagelse af den, ligesom et sorteringsrum mellem postkassen og arkivskabet. Posten ankommer, nogen åbner den, følger op på det, der skal følges op på, og først derefter bliver noget arkiveret. De pakker logikken ind i funktioner og efterligner store-objektets naturlige adfærd.
Som med alt andet inden for programmering findes der ikke én løsning, der passer til alt, så undersøg hvilken middleware, der passer bedst til dit problem. Den første løsning, som dokumentationen foreslår, er Redux Thunk. En thunk er en funktion, der returnerer en anden funktion, som udskyder arbejdet, indtil noget kalder den, og i Redux er dette "noget" dispatch. Denne middleware giver dig mulighed for at skabe handlinger, der er mere end blot almindelige objekter: de kan sende andre handlinger, sende andre Thunks og udføre asynkrone operationer internt.
Andre har vundet frem siden da. Redux-Saga modellerer asynkrone flows som sagas, hvilket er generator-funktioner: funktioner, der holder pause ved hvert trin og giver kontrollen tilbage til en motor, der bestemmer, hvad der skal ske derefter. Redux-Observable modellerer de samme flows som RxJS-streams, hvor hver handling er en begivenhed på en stream, som du kan filtrere, kombinere og annullere ved hjælp af RxJS-bibliotekets operatorer. Forskellige anvendelsesscenarier, og en vigtig detalje at kende, før du beslutter dig: Saga bliver stadig aktivt vedligeholdt med et stort fællesskab, mens Redux-Observable er stabil, men nu i vedligeholdelsestilstand, så overvej det i forhold til en langvarig kodebase.
En fjerde mulighed eksisterer sideløbende med dem frem for at konkurrere direkte. RTK Query er Redux Toolkits lag til datahentning: Du definerer dine API-endepunkter, og så genererer det logikken til hentning, caching og indlæsning for dig, så du slipper for at skrive asynkrone handlinger manuelt.

"Gør dit forarbejde" er ikke et råd. Når vi arbejder for kunder, vælger vi mellem de fire muligheder ud fra tre kriterier, i denne rækkefølge.
I praksis betyder det, at valget falder på Thunk eller RTK Query i de fleste af vores projekter, og de to kan sagtens eksistere side om side i det samme lager. [Forfatter bekræfter: hvor mange af vores nylige React-projekter endte med hvilken løsning, over hvilken periode, og ét projekt hvor det første kriterium var reelt komplekst, og svaret blev Saga eller Observable.] Hvis dit svar på det første kriterium er reelt komplekst, så tag den samtale, før du skriver koden, ikke efter.
For alle, der skal godkende arbejdet frem for at skrive det, koger de tre kriterier ned til noget, du kan holde op mod en plan. At indføre Thunk i en eksisterende Redux-kodebase tager få dages arbejde, fordi det er almindelig asynkron JavaScript, som dit team allerede skriver. At indføre Saga eller Observable tager uger, og omkostningen er tilbagevendende: Hver udvikler, du ansætter til den kodebase, skal lære generatorer eller RxJS, før de sikkert kan røre ved et dataflow, hvilket forlænger onboarding-processen i hele projektets levetid.
Det er den dyre del at fortryde valget. Efter et år er asynkron logik skrevet som sagas spredt ud over hele kodebasen, og at skille det ad er en migrering snarere end en refaktorering, på størrelse med skiftet fra Singleton til Redux beskrevet ovenfor.
Denne asymmetri er hele argumentet for at starte med den enkleste løsning, der opfylder kravet. At opgradere fra Thunk senere er additivt, da de to kan køre side om side i det samme lager. At nedgradere er ikke.
Ud af alle de populære løsninger på dette problem er Redux Thunk den letteste at forstå. Den er teknisk set ret tilgængelig, og i skrivende stund er det den tilgang, som Redux-dokumentationen foreslår til manuel datahentning.
Gennemgangen herunder bruger en helt ny app og et offentligt dog-API, så koden forbliver kort nok til at blive læst i én omgang. Det vigtigste er dog ikke selve trinene. Det er de to vurderinger, der ligger gemt i dem: hvad der hører hjemme i storen, hvad der bliver i komponenten, og hvorfor den lange version overhovedet eksisterer.
Vi starter her:
npx create-react-app doggos --template reduxDet giver dig en helt ny app, der bruger React med alle de Redux-moduler, vi skal bruge til denne korte vejledning. Vi kommer også til at arbejde med WoofBot API-tjenesten.
Først vurderingen. Kun racedata og anmodningsstatus kommer i storen, fordi mere end én del af applikationen har brug for dem. Alt, hvad der kun er relevant for en enkelt komponent, mens den er på skærmen – såsom hvilken race der er valgt i en dropdown – bliver i den komponent. Springer du det spørgsmål over, bliver din store en losseplads, hvilket skaber præcis den type debugging-problemer, som Redux var tiltænkt at løse.
Et slice er en Redux Toolkit-pakke, der holder en sektion af storen samlet sammen med de reducere og handlinger, der ændrer den. Denne her holder styr på alt relateret til din dog-API-respons.
// doggosSlice.js
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
breeds: [],
images: {},
loading: 'waiting',
};
export const doggosSlice = createSlice({
name: 'doggos',
initialState,
reducers: {
uploadBreeds: (state, action) => {
state.breeds = action.payload;
},
uploadBreedImage: (state, action) => {
state.images[action.payload.breed] = action.payload.image;
},
loadingState: (state, action) => {
state.loading = action.payload;
},
},
});
export const { uploadBreeds, uploadBreedImage, loadingState } = doggosSlice.actions;
export const selectBreeds = (state) => state.doggos.breeds;
export const selectBreedImage = (breed) => (state) => state.doggos.images[breed];
export const isLoading = (state) => state.doggos.loading === 'request';
export default doggosSlice.reducer;Vi har vores Actions:
uploadBreeds: bruges som en opsamling af al payload-information vedrørende hunderacerne.uploadBreedImage: bruges til at uploade specifikke billeder for bestemte racer, hvis det er nødvendigt.loadingState: bruges til at opdatere status for anmodningen.Og vores Selectors, funktionerne der læser en værdi ud af storen, så komponenter aldrig tilgår dens struktur direkte:
selectBreeds: returnerer et array med alle racerne i storen.selectBreedImage: returnerer billedet for en specifik race.isLoading: returnerer anmodningens status.Hvordan ville du normalt implementere denne udveksling mellem API'et og storen? Jeg ville placere det hele i en useEffect-hook, svarende til denne:
useEffect(() => {
dispatch(loadingState('request'));
fetch('https://dog.ceo/api/breeds/list/all')
.then((response) => response.json())
.then((data) => {
dispatch(uploadBreeds(Object.keys(data.message)));
dispatch(loadingState('waiting'));
})
.catch(() => {
dispatch(loadingState('error'));
});
}, [dispatch]);Hvad gør vi her?
Alternativt kan vi modtage en fejl fra API'et, hvilket stopper flowet ved trin 4 og sætter indlæsningstilstanden til Error.
Dette virker, og det er fint. Det har dog også flere ulemper. Hovedsageligt placerer det for meget logik i komponenten: den er ikke genanvendelig, og hvis du har brug for denne information et andet sted, skal du altid sikre dig, at denne komponent er indlæst først.
Komponentlogikken ser således ud:
useEffect(() => {
dispatch(fetchBreeds());
}, [dispatch]);Vi skal oprette en ny fetchBreeds-handling, der minder meget om den logik, vi tidligere havde i komponenten:
export const fetchBreeds = () => async (dispatch) => {
dispatch(loadingState('request'));
try {
const response = await fetch('https://dog.ceo/api/breeds/list/all');
const data = await response.json();
dispatch(uploadBreeds(Object.keys(data.message)));
dispatch(loadingState('waiting'));
} catch (error) {
dispatch(loadingState('error'));
}
};Denne simple ændring af placering løser de fleste af de problemer, vi havde. Vi har abstraheret kode ud af komponenten og gjort dette specifikke stykke logik genanvendeligt i hele kodebasen. Informationen er ikke længere bundet til monteringen af komponenten, så du kan sende en ny fetchBreeds-handling hvor som helst, og dataene vil blive indlæst.
export const fetchBreedImages = () => async (dispatch, getState) => {
const breeds = selectBreeds(getState());
for (const breed of breeds) {
const response = await fetch(`https://dog.ceo/api/breed/${breed}/images/random`);
const data = await response.json();
dispatch(uploadBreedImage({ breed, image: data.message }));
}
};Dette gør det også muligt for os at kæde Thunks sammen ved at dispatche en fra en anden, når logikken i vores handlinger bliver mere kompleks. Vi kan få direkte adgang til state via getState i stedet for at skulle bruge selectors. Du vil dog stadig ønske at bruge selectors, så enhver ændring i strukturen af din Redux-state ikke ødelægger dine Thunks.
Alt ovenstående er skrevet manuelt som et pædagogisk værktøj. Det viser, hvad en Thunk faktisk er: en funktion, du dispatcher, som modtager dispatch og getState. I et projekt, der bruger Redux Toolkit, ville du ikke skrive det på den måde. createAsyncThunk genererer de afventende, fuldførte og afviste handlinger for dig, så den indlæsningstilstand, du lige har set os sende tre gange, bliver noget, som reduceren håndterer én gang.
import { createAsyncThunk } from '@reduxjs/toolkit';
export const fetchBreeds = createAsyncThunk('doggos/fetchBreeds', async () => {
const response = await fetch('https://dog.ceo/api/breeds/list/all');
const data = await response.json();
return Object.keys(data.message);
});
extraReducers: (builder) => {
builder
.addCase(fetchBreeds.pending, (state) => {
state.loading = 'request';
})
.addCase(fetchBreeds.fulfilled, (state, action) => {
state.breeds = action.payload;
state.loading = 'waiting';
})
.addCase(fetchBreeds.rejected, (state, action) => {
state.loading = 'error';
state.error = action.error.message;
});
}To ting, som tutorials normalt springer over. De samme to ting, der går i stykker i produktion.
Fejl. En afvist Thunk bør placere en besked i storen, ikke bare en status. action.error.message ovenfor er minimum. I rigtige projekter beholder vi den fejlede forespørgsels egen fejl-payload ved at bruge rejectWithValue, så komponenten kan kende forskel på en 404 og en netværksfejl og sige noget brugbart til brugeren.
Annullering. fetchBreedImages-loopet ovenfor vil fortsætte med at køre, efter komponenten er blevet afmonteret, og et langsomt svar, der ankommer efter et nyere, vil overskrive det. createAsyncThunk giver dig et signal, som du kan sende videre til fetch for at løse det første problem, og en condition-indstilling til at forhindre en duplikeret forespørgsel i at starte for det andet. Har du brug for mere end det? Annullering er netop det kriterium, der peger på Saga eller Observable.
Thunks er testbare netop fordi de er almindelige funktioner, og dette er den praktiske årsag til at foretrække dem frem for logik begravet i en komponent. Ingen komponent der skal rendres. Ingen hook der skal simuleres.
For en håndskrevet Thunk skal du kalde den med en falsk dispatch og en falsk getState, og derefter validere hvad der blev sendt, og i hvilken rækkefølge:
it('dispatches request, then breeds, then waiting', async () => {
const dispatch = jest.fn();
global.fetch = jest.fn().mockResolvedValue({
json: () => Promise.resolve({ message: { husky: [], beagle: [] } }),
});
await fetchBreeds()(dispatch, () => ({}));
expect(dispatch.mock.calls.map(([action]) => action)).toEqual([
loadingState('request'),
uploadBreeds(['husky', 'beagle']),
loadingState('waiting'),
]);
});For en createAsyncThunk er der to separate ting, der skal testes, og det er værd at holde dem adskilt. Selve thunken testes ved at sende den til en rigtig store og validere den resulterende tilstand, hvilket tester den afventende og fuldførte sti fra ende til anden. Reduceren testes som en ren funktion ved at kalde den med en fetchBreeds.rejected-handling og kontrollere, at fejlen lander, hvor du forventer:
it('records the error message when the request is rejected', () => {
const action = { type: fetchBreeds.rejected.type, error: { message: 'Network error' } };
const state = doggosReducer(initialState, action);
expect(state.loading).toBe('error');
expect(state.error).toBe('Network error');
});Den afviste sti er den, som teams glemmer at teste. Det er også den, brugerne ser.
En Thunk, der kalder getState, læser hele storen, og det er en kobling, der er værd at håndtere tidligt. Læs gennem en selector, som fetchBreedImages gør, så afhænger Thunken af selector-kontrakten frem for strukturen i dit state-træ. Hvis du ændrer strukturen i dit slice senere, skal du kun ændre selectoren én gang i stedet for at lede gennem hver eneste Thunk, der tilgik den.
Det andet problem handler om størrelsen. En selector, der udleder en værdi, filtrerer racer eller opbygger et opslag, kører ved hver ændring i storen og returnerer et nyt objekt hver gang, hvilket får komponenter til at gen-rendre, selv når intet af det, de viser, har ændret sig. Redux Toolkit leverer createSelector til dette formål: den memoiserer resultatet, hvilket betyder, at den cacher det seneste output og kun genberegner, når inputtet rent faktisk ændrer sig.
import { createSelector } from '@reduxjs/toolkit';
export const selectBreedsWithImages = createSelector(
[selectBreeds, (state) => state.doggos.images],
(breeds, images) => breeds.filter((breed) => images[breed]),
);Brug almindelige selectors til direkte læsninger og memoiserede selectors til alt, der er udledt. I en lille store er forskellen usynlig. I den store, som dette projekt endte med, er det forskellen på en side, der reagerer øjeblikkeligt på et dispatch, og en, der hakker.
Hvis den tilstand, du henter, ejes af en server frem for din klient, RTK Query fjerner ovenstående kode fuldstændigt. Du definerer endpointet, og den genererede hook håndterer anmodningen, caching, loading-flags, deduplikering af samtidige anmodninger – hvilket betyder, at to komponenter, der beder om de samme data på samme tid, kun genererer ét netværkskald i stedet for to – samt invalidation ved skrivning, hvilket betyder, at en vellykket opdatering automatisk markerer de berørte cached data som forældede og henter dem på ny.
Ingen Thunks at skrive. Ingen loading-tilstand at dispatche. Redux-dokumentationen anbefaler nu RTK Query som standardmetoden til datahentning, og da hunderacerne i denne artikel er servertilstand, ville det være vores førstevalg i et rigtigt projekt.
Her er argumentet i korte træk. Reducers skal forblive rene, så asynkront arbejde hører hjemme i middleware. Redux Thunk er det rette standardvalg, fordi det ikke tilføjer en ny programmeringsmodel og dækker de tilfælde med succesfulde eller fejlede forespørgsler, som de fleste applikationer består af, og createAsyncThunk er måden, du bør skrive det på. Hvis dataene hører til en server, så brug RTK Query i stedet og spring Thunk helt over. Brug kun Saga eller Observable, når dine flows kræver annullering, koordinering eller planlægning, som de to første ikke kan håndtere, og kun hvis dit team stadig kan mestre den model om et år.
Til de problemer, jeg for nylig har stået overfor, var Thunks mere end rigeligt til at løse alle tænkelige specialtilfælde. Start der. Lad derefter et konkret behov, ikke en personlig præference, være det, der får dig til at skifte væk fra det.
Nej. Reducere skal være rene funktioner: Givet de samme argumenter returnerer de den samme næste tilstand uden bivirkninger eller API-kald. Asynkront arbejde placeres i middleware, som ligger mellem afsendelsen af en handling (dispatch) og reducerens modtagelse af den. Redux Thunk er standard-middlewaren til dette formål.
En Thunk er en almindelig funktion, der modtager dispatch og getState, så det er bare asynkron JavaScript, som du allerede kender. En Saga er en generator-funktion, og Redux-Saga er en motor til at køre disse generatorer, hvilket giver dig annullering, debouncing, genforsøg og koordinering mellem samtidige flows som indbyggede funktioner. Thunk er enklere. Saga er mere kapabel til komplekse flows, men kræver mere tid at lære og vedligeholde.
Brug createAsyncThunk. Den genererer automatisk pending-, fulfilled- og rejected-handlinger for dig, standardiserer fejlformatet og giver dig understøttelse af annullering via signal og condition. Skriv kun en Thunk manuelt, hvis den slet ikke sender en forespørgsel, for eksempel hvis den læser tilstand og sender en almindelig handling betinget.
Oftere mindre end teams antager. Hvis den tilstand, du administrerer, er en cache af serverdata, dækker RTK Query eller et data-fetching-bibliotek behovet, og hvis den er lokal for et lille komponenttræ, er React context eller komponenttilstand normalt nok. Redux er berettiget, når en stor mængde klient-ejet tilstand deles på tværs af fjerne dele af en stor applikation, og du har brug for sporbarheden fra navngivne handlinger og en historik, der kan inspiceres.
Fang fejlen inde i din Thunk og send den ind i storen som data, ikke bare som et statusflag. Med createAsyncThunk skal du returnere rejectWithValue(error), så den afviste handling indeholder API'ets egen fejl-payload, og derefter håndtere .rejected i extraReducers. På den måde kan en komponent kende forskel på en valideringsfejl og en netværksfejl og vise brugeren noget specifikt.
De fleste af de problemer med state, vi bliver tilkaldt for at løse, startede på samme måde som vores. Der blev ikke truffet nogen beslutning, og en ad hoc-løsning endte med at blive standarden for state-laget. Hvis det lyder som din applikation, kan vores teams se på, hvad din state reelt koster dig i form af tid brugt på debugging og onboarding, før vi anbefaler noget som helst. Tag en snak med os om det, og se hvordan vi griber leverancer an på vores softwareudvikling og kodeaudit sider, eller gennemse vores cases, herunder vores arbejde med React og Redux for Elephants Don't Forget, for at se processen i praksis.

Din daglige webudvikler, der kan lide at gemme sig i backend. Javascript og Ruby er min marmelade. Jeg fumler stadig med Docker, og mine bygninger går i stykker ret ofte.

Alexandra Mendes er Senior Growth Specialist hos Imaginary Cloud med 3+ års erfaring med at skrive om softwareudvikling, AI og digital transformation. Efter at have gennemført et frontend-udviklingskursus fik Alexandra nogle praktiske kodningsevner og arbejder nu tæt sammen med tekniske teams. Alexandra brænder for, hvordan nye teknologier former erhvervslivet og samfundet, og hun nyder at omdanne komplekse emner til klart og nyttigt indhold for beslutningstagere.
People who read this post, also found these interesting: