kontakta oss


Asynkron logik hör inte hemma i en Redux-reducer. Reducers är rena funktioner, så alla API-anrop, timers och prenumerationer måste ligga någon annanstans, och i Redux är det någon annanstans middleware. Redux Thunk är standardvalet för att komma igång. För de flesta team är det också slutstationen.
Det som följer är hämtat från en stor React- och Node.js-applikation som vi migrerade från ett ad hoc-tillståndslager. Den första halvan täcker beslutet och vad det innebär i form av kostnader; från "Hur man implementerar Redux Thunk" och framåt handlar det om själva implementeringen. Om du bara behöver beslutsunderlaget kan du sluta läsa vid urvalskriterierna.
När vi behövde fastställa en teknikstack för det här projektet var React det självklara valet för frontenden, med tanke på hur mycket visuell presentation och 3D-modellering arbetet innebar.
Vad vi däremot inte definierade var en strategi för tillståndshantering. Allt eftersom projektet utvecklades blev det ett problem, så teamet flyttade all logik för tillståndshantering till en klass implementerad som en Singleton: en enda delad instans som alla komponenter läser från och skriver till. Den hanterade tillståndsrelaterad lagring och händelser tillräckligt bra.
Tillräckligt bra, ett tag. Till slut blev den otillräcklig, och vi satte igång en plan för att hitta ett bättre alternativ. Det kom i form av Redux, med hjälp av introduktionen av Redux Toolkit, tidigare känt som Redux Starter Kit.
Singleton-mönstret var aldrig planen. Ingen väljer det frivilligt. Det är den viktigaste lärdomen från den här artikeln, eftersom ohanterat tillstånd sällan är ett tekniskt problem från början. Det visar sig som en leveranskostnad.
Föreställ dig en whiteboard på ett delat kontor som vem som helst kan skriva på, utan namn och utan tidsstämplar. Tavlan visar alltid det aktuella läget. Den berättar aldrig vem som ändrade något, när det skedde eller varför, så att ta reda på hur ett värde hamnade där innebär att du måste fråga varenda person som gått förbi.

Det var vår Singleton, och kostnaden landade på tre mätbara områden. Felsökningen tog längre tid eftersom spårning av ett felaktigt värde innebar att manuellt läsa igenom varje skrivning. [Författaren bekräftar: hur många utvecklartimmar per sprint teamet lade på den spårningen före migreringen.] Introduktionen av nya utvecklare tog längre tid eftersom den delade instansen saknade ett kontrakt som en ny utvecklare kunde läsa, så det enda sättet att lära sig tillståndsmodellen var att läsa varje komponent som rörde den. [Författaren bekräftar: hur lång tid det tog för en ny utvecklare att bli produktiv i tillståndslagret, före kontra efter.] Och varje ny funktion innebar en liten extra kostnad, eftersom någon var tvungen att manuellt räkna ut vilka delar av det delade objektet som var säkra att ändra.
Redux gjorde inte applikationen snabbare. Det gjorde tillståndslagret läsbart. Åtgärder namnges, ändringar loggas och historiken kan inspekteras under testning, så den tid vi förlorade på att undra "varifrån kom det här värdet" försvann till stor del. Om du överväger en migrering som den här, är det den avkastningen du bör mäta: inte körprestanda, utan de timmar ditt team för närvarande lägger på att manuellt återskapa tillstånd. Det är samma avkastning vi letar efter när vi genomför en kodgranskning av en befintlig kodbas.
Redux är en tillståndsbehållare för JavaScript-applikationer som låter dig kringgå Reacts naturliga enkelriktade dataflöde. Den innehåller en enda källa till sanning som du kan konsultera var som helst i din applikation, utan att behöva skicka ner tillstånd som props till andra komponenter.
Den låter dig även ändra detta tillstånd genom fördefinierade åtgärder, samtidigt som den sparar en historik över dessa åtgärder och ändringar som kan granskas vid testning. Namngivna skribenter, tidsstämplade. Whiteboarden, äntligen, med en loggbok bifogad.
När Facebook presenterade React för världen var det redan uppenbart för dem hur props kunde bli ett stort hinder vid en viss komplexitetsnivå. För att motverka detta introducerade de ett koncept vid sidan av React som kallas Flux, vilket beskriver hur en store bör fungera i kombination med React. Redux, så som vi känner det idag, härstammar från en proof of concept som Dan Abramov byggde medan han experimenterade med Flux-principer inför React Europe.
Redux används i storskaliga applikationer där en interaktion med en komponent kan sprida ändringar till hela sidan. Istället för att skapa callbacks på applikationens toppnivå och skicka ner dem genom lagren, konsulterar du helt enkelt din store.
I mitt projekt var det logiskt eftersom applikationen hade vuxit till en punkt där tillståndsrelaterade props skickades ner genom flera lager av komponenter. Det gjorde koden svårläst och ännu svårare att felsöka. (Samma resonemang gäller för mobil. Om du integrerar Redux i React Native går vi igenom konfigurationen i vår guide för React Native med Redux.)
Redux gjorde omedelbar skillnad när vi började migrera gammal kod till det. Koden blev lättare att följa, teamet lärde sig det snabbt och antalet props som flöt runt i applikationen minskade drastiskt.
Men det var inte perfekt. Reducers natur skapar problem så fort man försöker inkludera hämtning av information i dem, och det är något som Redux-communityt har brottats med under en mycket lång tid.
Reducers är i teorin rena funktioner, enligt dokumentationen själv.
Givet samma argument bör de beräkna nästa tillstånd och returnera det. Inga överraskningar. Inga sidoeffekter. Inga API-anrop. Inga mutationer. Bara en beräkning.
Så var bör man placera asynkrona anrop i Redux?
Åtgärder (Actions) borde vara det självklara svaret, men den grundläggande implementeringen av en åtgärd är inget annat än ett vanligt JavaScript-objekt som du använder för att skicka information till din store. Därför skapade communityt middlewares: funktioner som ligger mellan att en åtgärd skickas (dispatch) och att reducern tar emot den, som ett sorteringsrum mellan brevlådan och arkivskåpet. Posten anländer, någon öppnar den, uträttar det som behöver göras, och först därefter arkiveras något. De kapslar in logiken i funktioner och efterliknar storens naturliga beteende.
Som med allt inom programmering finns det ingen universallösning, så undersök vilken middleware som passar ditt problem bäst. Den första lösningen som föreslås i dokumentationen är Redux Thunk. En thunk är en funktion som returnerar en annan funktion, vilket skjuter upp arbetet tills något anropar den, och i Redux är detta något dispatch. Denna middleware gör att du kan skapa åtgärder som är mer än bara enkla objekt: de kan skicka andra åtgärder, skicka andra Thunks och utföra asynkrona operationer inuti dem.
Andra har vunnit mark sedan dess. Redux-Saga modellerar asynkrona flöden som sagas, vilket är generatorfunktioner: funktioner som pausar vid varje steg och lämnar över kontrollen till en motor som bestämmer vad som händer härnäst. Redux-Observable modellerar samma flöden som RxJS-strömmar, där varje åtgärd är en händelse i en ström som du kan filtrera, kombinera och avbryta med hjälp av RxJS-bibliotekets operatorer. Olika användningsområden, och en varning värd att känna till innan du bestämmer dig: Saga underhålls fortfarande aktivt av en stor community, medan Redux-Observable är stabilt men nu i underhållsläge, så väg det mot en långlivad kodbas.
Ett fjärde alternativ finns vid sidan av dem snarare än att konkurrera direkt. RTK Query är Redux Toolkits lager för datahämtning: du deklarerar dina API-slutpunkter och den genererar logiken för hämtning, cachning och laddning åt dig, så att du slipper skriva asynkrona åtgärder för hand.

"Gör din research" är inget råd. I kundprojekt väljer vi mellan fyra alternativ utifrån tre kriterier, i den här ordningen.
I praktiken innebär detta Thunk eller RTK Query i de flesta projekt vi driver, och de två samexisterar smidigt i samma store. [Author to confirm: how many of our recent React projects landed on each option, over what period, and one project where criterion one was genuinely complex and the answer came out as Saga or Observable.] Om ditt svar på det första kriteriet är genuint komplext, ta den diskussionen innan du skriver koden, inte efter.
För den som godkänner arbetet snarare än utför det, kokar dessa tre kriterier ner till något du kan planera efter. Att införa Thunk i en befintlig Redux-kodbas tar några dagar, eftersom det är vanlig asynkron JavaScript som ditt team redan skriver. Att införa Saga eller Observable tar veckor, och kostnaden är återkommande: varje utvecklare du anställer till kodbasen måste lära sig generatorer eller RxJS innan de säkert kan röra ett dataflöde, vilket förlänger introduktionstiden under hela projektets livslängd.
Att ändra beslutet i efterhand är den dyra biten. Efter ett år är asynkron logik skriven som sagas spridd över hela kodbasen, och att plocka isär den är en migrering snarare än en refaktorering, i ungefär samma skala som flytten från Singleton till Redux som beskrevs ovan.
Den asymmetrin är hela argumentet för att börja med det enklaste alternativet som uppfyller kraven. Att uppgradera från Thunk senare är additivt, eftersom de två kan köras sida vid sida i samma store. Att nedgradera är det inte.
Av alla populära lösningar på detta problem är Redux Thunk den som är lättast att förstå. Den är tekniskt sett ganska lättillgänglig och i skrivande stund är det den metod som Redux-dokumentationen rekommenderar för manuell datahämtning.
Genomgången nedan använder en ny app och ett publikt hund-API, så att koden förblir tillräckligt kort för att läsas i ett svep. Det viktiga är dock inte stegen, utan de två avvägningar som ligger till grund för dem: vad som hör hemma i storen och vad som stannar i komponenten, samt varför den mer utförliga versionen överhuvudtaget existerar.
Vi börjar här:
npx create-react-app doggos --template reduxDet ger dig en helt ny app med React och alla Redux-moduler vi behöver för den här korta guiden. Vi kommer även att arbeta med API-tjänsten WoofBot.
Först avvägningen. Endast rasdata och förfrågningsstatus hamnar i storen, eftersom mer än en del av applikationen behöver dem. Allt som bara rör en enskild komponent medan den visas på skärmen, till exempel vilken ras som är vald i en rullista, stannar i den komponenten. Hoppar du över den frågan blir din store en soptipp, vilket skapar precis den typ av felsökningsproblem som Redux var tänkt att lösa.
En slice är ett Redux Toolkit-paket som håller samman en del av storen tillsammans med de reducers och actions som ändrar den. Den här håller ordning på allt som rör ditt hund-API-svar.
// 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 våra Actions:
uploadBreeds: används som en samlingsplats för all payload-information gällande hundraserna.uploadBreedImage: används för att ladda upp specifika bilder för vissa raser, vid behov.loadingState: används för att uppdatera statusen för förfrågan.Och våra Selectors, funktionerna som läser ett värde från storen så att komponenter aldrig behöver komma åt dess struktur direkt:
selectBreeds: returnerar en array med alla raser i storen.selectBreedImage: returnerar bilden för en specifik ras.isLoading: returnerar statusen för förfrågan.Hur skulle du normalt implementera detta utbyte mellan API:et och storen? Jag skulle placera allt i en useEffect-hook, ungefär så här:
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]);Vad gör vi här?
Alternativt kan vi få ett felmeddelande från API:et, vilket avbryter flödet vid steg 4 och sätter laddningsstatusen till Error.
Detta fungerar, och det är helt okej. Det har dock flera nackdelar. Framför allt lägger det för mycket logik i komponenten: den blir inte återanvändbar, och om du vill använda informationen någon annanstans måste du alltid se till att komponenten har laddats först.
Komponentlogiken ser ut så här:
useEffect(() => {
dispatch(fetchBreeds());
}, [dispatch]);Vi behöver skapa en ny fetchBreeds-action som liknar logiken vi tidigare hade 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'));
}
};Denna enkla flytt löser de flesta av problemen vi hade. Vi har abstraherat ut kod från komponenten och gjort denna specifika logik återanvändbar i hela kodbasen. Informationen är inte längre bunden till att komponenten monteras, så du kan anropa en ny fetchBreeds-action var som helst och datan kommer att laddas.
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 }));
}
};Detta gör det också möjligt för oss att kedja Thunks, där vi dispatchar en från en annan när logiken i våra actions blir mer komplex. Vi kan komma åt state direkt via getState, istället för att behöva selectors. Du vill dock fortfarande använda selectors, så att eventuella ändringar i strukturen på ditt Redux-state inte förstör dina Thunks.
Allt ovan är skrivet manuellt i utbildningssyfte. Det visar vad en Thunk faktiskt är: en funktion du dispatchar, som får tillgång till dispatch och getState. I ett projekt som använder Redux Toolkit skulle du inte skriva det på det sättet. createAsyncThunk genererar väntande, slutförda och avvisade åtgärder åt dig, så att laddningstillståndet du just såg oss skicka tre gånger blir något som reducern hanterar en gång.
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;
});
}Två saker som guiderna oftast hoppar över. Samma två saker som går sönder i produktion.
Fel. En avvisad Thunk bör placera ett meddelande i storen, inte bara en status. action.error.message ovan är minimum. I verkliga projekt behåller vi den misslyckade förfrågans egen fel-payload genom att använda rejectWithValue, så att komponenten kan skilja på ett 404-fel och ett nätverksfel och säga något användbart till användaren.
Avbrytning. Loopen fetchBreedImages ovan fortsätter att köras efter att komponenten avmonterats, och ett långsamt svar som anländer efter ett nyare kommer att skriva över det. createAsyncThunk ger dig en signal som du kan skicka till fetch för det första problemet, och ett condition-alternativ för att förhindra att en dubblettförfrågan startar för det andra. Behöver du mer än så? Avbrytning är precis det kriterium som pekar mot Saga eller Observable.
Thunks är testbara just för att de är vanliga funktioner, och detta är den praktiska anledningen till att föredra dem framför logik som är begravd i en komponent. Ingen komponent att rendera. Ingen hook att simulera.
För en handskriven Thunk, anropa den med en falsk dispatch och en falsk getState, och gör sedan en kontroll av vad som skickades och i vilken ordning:
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'),
]);
});För en createAsyncThunk finns det två separata saker att testa, och det är värt att hålla isär dem. Själva thunken testas genom att skicka den mot en riktig store och kontrollera det resulterande tillståndet, vilket testar den väntande och slutförda vägen från början till slut. Reducern testas som en ren funktion genom att anropa den med en fetchBreeds.rejected-åtgärd och kontrollera att felet hamnar där du förväntar dig:
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 avvisade vägen är den som team glömmer att testa. Det är också den som användarna ser.
En Thunk som anropar getState läser hela storen, och det är en koppling som är värd att hantera tidigt. Läs via en selector, som fetchBreedImages gör, så beror Thunken på selectorns kontrakt snarare än på formen av ditt state-träd. Om du ändrar strukturen på din slice senare behöver du bara ändra selectorn en gång, istället för att leta igenom varje Thunk som använde den.
Det andra problemet rör storleken. En selector som härleder ett värde, filtrerar raser eller bygger en uppslagstabell körs vid varje ändring i storen och returnerar ett nytt objekt varje gång, vilket gör att komponenter renderas om även när inget av det de visar har ändrats. Redux Toolkit tillhandahåller createSelector för detta: den memorerar resultatet, vilket innebär att den cachar det senaste utdata och räknar om det endast när indata faktiskt har ändrats.
import { createSelector } from '@reduxjs/toolkit';
export const selectBreedsWithImages = createSelector(
[selectBreeds, (state) => state.doggos.images],
(breeds, images) => breeds.filter((breed) => images[breed]),
);Vanliga selectors för direktläsning, memorerade sådana för allt som är härlett. I en liten store märks ingen skillnad. I den store som det här projektet slutade med är det skillnaden mellan en sida som svarar direkt på ett anrop och en som hackar.
Om tillståndet du hämtar ägs av en server snarare än av din klient, RTK Query tar bort koden ovan helt och hållet. Du deklarerar endpointen, och den genererade hooken hanterar anropet, cachningen, laddningsflaggor, deduplicering av samtidiga anrop – vilket innebär att två komponenter som efterfrågar samma data samtidigt bara skapar ett nätverksanrop istället för två – samt ogiltigförklaring vid skrivning, vilket innebär att en lyckad uppdatering automatiskt markerar den berörda cachade datan som inaktuell och hämtar den på nytt.
Inga Thunks att skriva. Inget laddningstillstånd att skicka. Redux-dokumentationen lär nu ut RTK Query som standardmetod för datahämtning, och hundraserna i den här artikeln är server-state, så i ett verkligt projekt är det här vad vi skulle välja i första hand.
Här är argumentationen i korthet. Reducers måste förbli rena, så asynkront arbete hör hemma i middleware. Redux Thunk är det rätta standardvalet eftersom det inte tillför någon ny programmeringsmodell och täcker de fall med lyckade eller misslyckade anrop som de flesta applikationer består av, och createAsyncThunk är sättet du bör skriva det på. Om datan tillhör en server, använd RTK Query istället och hoppa över Thunk helt. Vänd dig till Saga eller Observable endast när dina flöden kräver avbrytning, koordinering eller schemaläggning som de två första inte kan hantera, och bara om ditt team fortfarande kan hantera den modellen om ett år.
För de problem jag stött på den senaste tiden har Thunks varit mer än tillräckligt för att lösa alla specialfall. Börja där. Låt sedan ett konkret krav, inte en personlig preferens, vara det som får dig att gå vidare.
Nej. Reducers måste vara rena funktioner: givet samma argument returnerar de samma nästa tillstånd, utan sidoeffekter eller API-anrop. Asynkront arbete sker i middleware, som ligger mellan att en action skickas (dispatch) och att reducern tar emot den. Redux Thunk är standard-middleware för detta.
En Thunk är en vanlig funktion som tar emot dispatch och getState, så det är bara asynkron JavaScript som du redan kan. En Saga är en generatorfunktion, och Redux-Saga är en motor för att köra dessa generatorer, vilket ger dig avbrytning, debouncing, retries och koordinering mellan samtidiga flöden som inbyggda funktioner. Thunk är enklare. Saga är mer kraftfullt för komplexa flöden men kräver mer tid att lära sig och underhålla.
Använd createAsyncThunk. Den genererar actions för pending, fulfilled och rejected åt dig, standardiserar felhanteringen och ger dig stöd för avbrytning via signal och condition. Skriv en Thunk för hand endast när den inte skickar någon förfrågan alls, till exempel en som läser tillstånd och skickar en vanlig action villkorligt.
Mer sällan än vad team ofta tror. Om tillståndet du hanterar är en cache av serverdata täcker RTK Query eller ett bibliotek för datahämtning behovet, och om det är lokalt för ett litet komponentträd räcker oftast React context eller komponenttillstånd. Redux är motiverat när mycket klientägt tillstånd delas mellan avlägsna delar av en stor applikation och du behöver spårbarheten hos namngivna actions och en inspekterbar historik.
Fånga felet inuti din Thunk och skicka in det i storen som data, inte bara som en statusflagga. Med createAsyncThunk, returnera rejectWithValue(error) så att den avvisade actionen bär på API:ets egen fel-payload, och hantera sedan .rejected i extraReducers. På så sätt kan en komponent skilja på ett valideringsfel och ett nätverksfel och visa användaren specifik information.
De flesta problem med tillståndshantering som vi kallas in för att lösa började på samma sätt som våra egna. Inga beslut fattades, och en ad hoc-lösning blev standard för tillståndslagret. Om det låter som din applikation kan våra team undersöka vad ditt nuvarande tillstånd faktiskt kostar dig i form av tid för felsökning och introduktion av nya utvecklare, innan vi ger några rekommendationer. Prata med oss om det och se hur vi närmar oss leverans på våra mjukvaruutveckling och kodgranskning sidor, eller bläddra bland våra case-studier, inklusive vårt arbete med React och Redux för Elephants Don't Forget, för att se processen i praktiken.

Din dagliga webbutvecklare som gillar att gömma sig i backend. Javascript och Ruby är min sylt. Jag fumlar fortfarande med Docker och mina byggnader går sönder ganska ofta.

Alexandra Mendes är Senior Growth Specialist på Imaginary Cloud med 3+ års erfarenhet av att skriva om mjukvaruutveckling, AI och digital transformation. Efter att ha avslutat en frontend-utvecklingskurs tog Alexandra upp några praktiska kodningskunskaper och arbetar nu nära med tekniska team. Alexandra brinner för hur ny teknik formar affärer och samhälle och tycker om att förvandla komplexa ämnen till tydligt och användbart innehåll för beslutsfattare.
People who read this post, also found these interesting: