use-async-action
FC-0016async-actionprimitivev1.0.0De toestandsmachine onder elke knop die iets wegstuurt: bezig, gelukt of mislukt, en vanzelf terug naar rust. Voorkomt dubbel afvuren, verouderde uitkomsten en state na unmount.
status: rust
status: rust
Ram er maar op tijdens ‘bezig’: een tweede aanroep valt weg, en na 1,4 s valt hij vanzelf terug naar rust.
primitives/ui/use-async-action.ts
/**
* useAsyncAction — de toestandsmachine onder elke knop die iets wegstuurt:
* bezig → gelukt of mislukt → vanzelf weer neutraal.
*
* Wat hij vooral doet is fouten voorkomen die je pas in productie ziet:
* - dubbel afvuren kan niet (een tweede aanroep tijdens 'bezig' valt weg),
* - een trage eerste poging kan de uitkomst van een latere niet overschrijven
* (elke run heeft een nummer; alleen de laatste telt),
* - na unmount wordt er geen state meer gezet, en de timer wordt opgeruimd.
*
* De actie zelf blijft van het project (V6): deze hook weet niet of er een
* mail, een betaling of een save achter zit.
*/
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
export type AsyncActionStatus = "rust" | "bezig" | "gelukt" | "mislukt";
export interface UseAsyncActionOptions {
/** Wat er moet gebeuren; mag een promise teruggeven of gooien. */
action: () => unknown;
/** Hoe lang gelukt/mislukt blijft staan voor hij terugvalt naar rust (ms). */
resetAfter?: number;
onError?: (error: unknown) => void;
}
export interface UseAsyncActionResult {
status: AsyncActionStatus;
bezig: boolean;
run: () => void;
reset: () => void;
}
export function useAsyncAction({
action,
resetAfter = 1400,
onError,
}: UseAsyncActionOptions): UseAsyncActionResult {
const [status, setStatus] = useState<AsyncActionStatus>("rust");
const fase = useRef<AsyncActionStatus>("rust");
const runNummer = useRef(0);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const levend = useRef(true);
/* Refs zodat een nieuwe closure per render de lopende run niet verstoort. */
const doe = useRef(action);
const bijFout = useRef(onError);
useEffect(() => {
doe.current = action;
bijFout.current = onError;
});
const stopTimer = useCallback(() => {
if (timer.current) {
clearTimeout(timer.current);
timer.current = null;
}
}, []);
const reset = useCallback(() => {
runNummer.current += 1;
stopTimer();
fase.current = "rust";
setStatus("rust");
}, [stopTimer]);
const run = useCallback(() => {
if (fase.current === "bezig") return; /* dubbel afvuren bestaat niet */
stopTimer();
const nummer = ++runNummer.current;
fase.current = "bezig";
setStatus("bezig");
const landen = (volgende: "gelukt" | "mislukt") => {
if (!levend.current || nummer !== runNummer.current) return;
stopTimer();
fase.current = volgende;
setStatus(volgende);
timer.current = setTimeout(() => {
if (!levend.current || nummer !== runNummer.current) return;
fase.current = "rust";
setStatus("rust");
}, resetAfter);
};
Promise.resolve()
.then(() => doe.current())
.then(
() => landen("gelukt"),
(error: unknown) => {
bijFout.current?.(error);
landen("mislukt");
},
);
}, [resetAfter, stopTimer]);
useEffect(() => {
levend.current = true;
return () => {
levend.current = false;
stopTimer();
};
}, [stopTimer]);
return { status, bezig: status === "bezig", run, reset };
}