← alle entries

use-async-action

FC-0016async-actionprimitivev1.0.0

De 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.

zelfde component — andere tokens
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 };
}