Nepředvídatelnost asynchronního UI
Velmi často opomenutou chybou v případě budování asynchronního UI pomocí hooků je neošetření možné race condition. Ta vznikne jednoduše, přesto může být poměrně obtížné ji později odhalit.
Asynchronní efekt
Mějme třeba seznam názvů receptů, na které jde klikat a vyvolat tak fetch detailu konkrétního receptu. Ten se později zobrazí nad seznamem a zároveň chceme zvýraznit vybraný název v seznamu:
useEffect(() => {
asyncFetch(id).then(setState)
}, [id])
Problém je, že asyncFetch
může běžet jakkoliv dlouho. Uživatel tak může kliknout na jeden recept na pomalém připojení, v zápětí lehce změnit svoji polohu, chytit tak lepší signál a kliknout na název jiného receptu. Druhý recept se zvýrazní, načte se rychleji a na chvíli se zobrazí jeho detail. Jenže za nějakou dobu se donačte detail prvně kliknutého (pomalu načítaného) receptu a špatný, inkonzistetní stav je na světě.
Dalším průvodním jevem špatně použitého
useEffect
hooku je varovánísetState(…): Can only update a mounted or mounting component. This usually means you called setState() on an unmounted component.
Jde o stejný problém, kdy se snažíte změnit stav již odebrané komponenty.
Následující příklad ukazuje myšlenku správného řešení. To spočívá v tom, že efekt znevalidní nějaký interní flag jakmile dojde k jeho nahrazení nebo odebrání. Asynchronní akce před změnou vnějšího stavu (jenž vede ke stavu špatnému) zkontroluje validitu a podle toho buď stav změní, nebo výsledek jednoduše ignoruje.
useEffect(() => {
let valid = true
asyncFetch(id).then((data) => {
if (!valid) return
setState(data)
})
return () => {
valid = false
}
}, [id])
O něco robustnější řešení je k dispozici jako use-safe-effect-hook package.
Asynchronní akce v callbacku
Druhou velmi častou chybu v práci s asynchronní aktualizací stavu, vedoucí taktéž k možné race condition, je nedostatečné ošetření v useCallback
hooku (nebo v callbacku obecně).
Mějme klasický layout, na jedné straně filtry a na straně druhé výsledky načítané asynchronně na základě vybraných filtrů. Zredukovaný problémový příklad může vypadat následovně:
<Select
onChange={(value) => {
asyncFetch(value).then(updateState)
}}
/>
Problém je stejný jako u předchozího příkladu s recepty - uživatel může rychle měnit vybrané hodnoty v selectu, zatímco asyncFetch
může vracet výsledky různě dlouho, což může vést ke špatnému stavu - hodnota selectu nebude sedět se zobrazenými výsledky.
Jedním z řešení je zavést unikátní id/verzi (třeba Math.random
, ale spíš něco jako nanoid). To zapsat do stavu a přiřadit ho k async akci. Po skončení akce pak stačí porovnat poslední uložené id ve stavu s id akce - pokud sedí, vše je konzistentní a můžeme externí stav aktualizovat; pokud ne, výsledek akce se ignoruje:
const reducer = (state, action) => {
if (action.type === 'START')
return { ...state, id: action.id }
if (action.type === 'END' && state.id === action.id)
return { ...state, data: action.data }
return state
}
// ...
const [state, dispatch] = useReducer(reducer)
<Select
onChange={(value) => {
const id = nanoid()
dispatch({ type: 'START', id })
asyncFetch(value).then((data) => {
dispatch({ type: 'END', id, data })
})
}}
/>
Druhým řešením je použít reaktivní přístup a samozřejmě správně ošetřený useSafeEffect
:
const [value, setValue] = useState()
useSafeEffect(({ checkEffectValidity }) => {
asyncFetch(value)
.then(checkEffectValidity)
.then(updateState)
}, [value])
<Select onChange={setValue} />
Příště se podíváme, jak problém s race conditions řešit možná ještě lépe a elegantněji.