Skip to content

React:Examples:RequestButton

React에서 fetch api 를 사용한 request button component. SWR, react-query 같은 라이브러리로 어느정도 대체 가능.

TypeScript Code

'use client';

import {useRouter} from 'next/navigation';
import {
  type HTMLAttributes,
  type PropsWithChildren,
  useEffect,
  useMemo,
  useState,
} from 'react';
import {toastError} from '@/app/components/toast';
import {HttpStatusError} from '@/app/exceptions';
import SvgSpinners270Ring from '@/app/icons/spinners/SvgSpinners270Ring';
import useTranslation from '@/app/libs/i18n/client';

export const DEFAULT_ERROR_TIMEOUT_MILLISECONDS = 4_000;

type OnClick = () => Promise<void>;

// eslint-disable-next-line no-unused-vars
type OnChangePending = (pending: boolean) => Promise<void>;

type OnComplete = () => Promise<void>;

// eslint-disable-next-line no-unused-vars
type OnError = (error: string) => Promise<void>;

type ErrorFeedbackType = 'label' | 'toast';

interface RequestButtonProps
  extends Omit<
    PropsWithChildren<HTMLAttributes<HTMLButtonElement>>,
    'lang' | 'onClick' | 'onError'
  > {
  lng?: string;
  errorTimeout?: number;
  disabled?: boolean;
  noRefresh?: boolean;
  errorFeedbackType?: ErrorFeedbackType;
  noErrorFeedback?: boolean;
  recoverPendingState?: boolean;
  onClick?: OnClick;
  onChangePending?: OnChangePending;
  onComplete?: OnComplete;
  onError?: OnError;
  spinnerClassName?: string;
}

export default function RequestButton(props: RequestButtonProps) {
  const {
    children,
    lng,
    errorTimeout,
    disabled,
    noRefresh,
    errorFeedbackType,
    noErrorFeedback,
    recoverPendingState,
    onClick,
    onChangePending,
    onComplete,
    onError,
    spinnerClassName,
    className,
    ...attrs
  } = props;
  const {t} = useTranslation(lng, 'http-status');
  const [pending, setPending] = useState<undefined | true>();
  const [error, setError] = useState<undefined | string>();
  const [errorTimeoutId, setErrorTimeoutId] = useState<undefined | number>();
  const router = useRouter();

  const isDisabled = useMemo(() => {
    return disabled || pending;
  }, [disabled, pending]);

  useEffect(() => {
    return () => {
      if (typeof errorTimeoutId !== 'undefined') {
        clearTimeout(errorTimeoutId);
      }
    };
  }, [errorTimeoutId]);

  const handleClick = async () => {
    if (pending) {
      return;
    }

    if (typeof errorTimeoutId !== 'undefined') {
      clearTimeout(errorTimeoutId);
      setError(undefined);
      setErrorTimeoutId(undefined);
      return;
    }

    setPending(true);
    if (onChangePending) {
      await onChangePending(true);
    }

    console.assert(typeof error === 'undefined');
    console.assert(typeof errorTimeoutId === 'undefined');

    try {
      if (onClick) {
        await onClick();
      }
    } catch (e) {
      setPending(undefined);
      if (onChangePending) {
        await onChangePending(false);
      }

      let errorMessage: string;
      if (e instanceof HttpStatusError) {
        errorMessage = t(`http_status.${e.statusCode}`, {defaultValue: e.message});
      } else {
        errorMessage = String(e);
      }

      if (onError) {
        await onError(errorMessage);
      }

      if (!noErrorFeedback) {
        if (errorFeedbackType === 'label') {
          setError(errorMessage);
          const timeoutId = setTimeout(() => {
            setError(undefined);
            setErrorTimeoutId(undefined);
          }, errorTimeout ?? DEFAULT_ERROR_TIMEOUT_MILLISECONDS);
          setErrorTimeoutId(timeoutId as unknown as number);
        } else {
          toastError(errorMessage);
        }
      }
      return;
    }

    if (!noRefresh) {
      router.refresh();
    }
    if (onComplete) {
      await onComplete();
    }
    if (recoverPendingState) {
      setPending(undefined);
      if (onChangePending) {
        await onChangePending(false);
      }
    }
  };

  const ButtonBody = () => {
    if (pending) {
      return <SvgSpinners270Ring className={spinnerClassName} />;
    }
    if (error) {
      return <p className="text-clip">{error}</p>;
    }
    return children;
  };

  const buttonClassName = useMemo(() => {
    const classes = className?.split(' ').map(v => v.trim()) ?? [];
    if (classes.findIndex(v => v === 'btn') === -1) {
      classes.push('btn');
    }
    if (isDisabled && classes.findIndex(v => v === 'btn-disabled') === -1) {
      classes.push('btn-disabled');
    }
    return classes.join(' ');
  }, [className, isDisabled]);

  return (
    <button
      type="button"
      role="button"
      lang={lng}
      className={buttonClassName}
      onClick={handleClick}
      data-disabled={isDisabled}
      aria-disabled={isDisabled}
      data-error={error}
      {...attrs}
    >
      <ButtonBody />
    </button>
  );
}

See also