import React, { useCallback, useEffect, useRef, useState } from 'react'; import PropTypes from 'prop-types'; import makeCancellable from 'make-cancellable-promise'; type Config = Omit< React.ComponentPropsWithoutRef, 'as' | 'onClick' >; // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyFunction = (...args: any[]) => void; type AsyncMaybe = T extends AnyFunction ? (...args: Parameters) => ReturnType | Promise> : never; type OnClickType = React.ComponentPropsWithoutRef['onClick']; export type AsyncButtonProps = { as?: T; errorConfig?: Config; onClick?: AsyncMaybe>; pendingConfig?: Config; resetTimeout?: number; successConfig?: Config; } & Config; type PolymorphicRef = React.ComponentPropsWithRef['ref']; const STATES = { ERROR: 'error', INIT: 'init', PENDING: 'pending', SUCCESS: 'success', } as const; const AsyncButton = React.forwardRef( ( { as, errorConfig, onClick, pendingConfig, resetTimeout = 2000, successConfig, ...otherProps }: AsyncButtonProps, ref?: PolymorphicRef, ) => { const [buttonState, setButtonState] = useState<(typeof STATES)[keyof typeof STATES]>( STATES.INIT, ); const cancellablePromise = useRef>(); const timeout = useRef>(); useEffect( () => () => { if (cancellablePromise.current) { cancellablePromise.current.cancel(); } clearTimeout(timeout.current); }, [], ); const onClickInternal = useCallback( (event: React.MouseEvent) => { if (!onClick) { return; } clearTimeout(timeout.current); const onSuccess = () => { setButtonState(STATES.SUCCESS); }; const onError = () => { setButtonState(STATES.ERROR); }; const finallyCallback = () => { timeout.current = setTimeout(() => { setButtonState(STATES.INIT); }, resetTimeout); }; try { const result = onClick(event); setButtonState(STATES.PENDING); if (result instanceof Promise) { cancellablePromise.current = makeCancellable(result); cancellablePromise.current.promise .then(onSuccess) .catch(onError) .finally(finallyCallback); } else { onSuccess(); finallyCallback(); } } catch (error) { onError(); finallyCallback(); } }, [onClick, resetTimeout], ); const Component = as || 'button'; const buttonConfig: Config | null | undefined = (() => { switch (buttonState) { case STATES.ERROR: return errorConfig; case STATES.PENDING: return pendingConfig; case STATES.SUCCESS: return successConfig; default: return null; } })(); return ( ); }, ); AsyncButton.displayName = 'AsyncButton'; const configProps = { children: PropTypes.node, className: PropTypes.string, }; const isConfigObject = PropTypes.shape(configProps); AsyncButton.propTypes = { ...configProps, errorConfig: isConfigObject, onClick: PropTypes.func, pendingConfig: isConfigObject, resetTimeout: PropTypes.number, successConfig: isConfigObject, }; export default AsyncButton as ( props: AsyncButtonProps & { ref?: PolymorphicRef }, ) => React.ReactElement | null;