[React] Error boundary, Suspense 써보기 with React Query, Wagmi

bluejoy·2023년 10월 24일
1

React

목록 보기
16/19

글을 쓰게 된 이유

  • 글만으로는 Concurrent UI Pattern을 사용한 선언형 컴포넌트의 장점이 잘 와닿지 않았다.

    Concurrent UI Pattern을 사용하지 않은 컴포넌트를 「명령형 컴포넌트」로, Concurrent UI Pattern을 사용한 컴포넌트를 「선언형 컴포넌트」로 표현하고 있음을 인지하시고 읽어주세요.
    카카오 기술 블로그 - React Query와 함께 Concurrent UI Pattern을 도입하는 방법

  • 그러므로 직접 내 코드를 고쳐보면서 장점을 느껴보기로 했다.

예제 1

스크린샷

  • loading

  • error(수정 후)

기존 코드

// SelectNftStep.tsx
interface SelectNftStepProps {
  address: string;
  onNext: () => void;
}

export const SelectNftStep = ({
  address,
  onNext,
}: SelectNftStepProps): ReactElement => {
  const [network, setNetwork] = useState<Network>(Network.ETH_MAINNET);
  const { data, isLoading } = useQuery({
    queryKey: ["nfts", address, network],
    queryFn: async () => {
      if (address == null) return;
      const alchemy = getAlchemy(network);
      return await alchemy.nft.getNftsForOwner(address);
    },
    staleTime: 1000 * 60 * 5, // 5 minutes
  });
  return (
    <Box css={pageContentStyles}>
      <Box
        css={css`
          display: flex;
          flex-direction: row;
          align-items: center;
          gap: 4px;
        `}
      >
        <Typography variant="h4">Select NFT</Typography>
        <NetworkSelect network={network} onNetworkChange={setNetwork} />
      </Box>

      {isLoading ? (
        <NftPreviews.Skeleton />
      ) : (
        data && <NftPreviews.Component data={data} onNext={onNext} />
      )}
    </Box>
  );
};
// NftPreviews.component.tsx
import { NftPreview } from "@/presentation/common/components/NftPreview";
import { Box } from "@mui/material";
import { OwnedNftsResponse } from "alchemy-sdk";
import { ReactElement } from "react";
import { useNftQrFormContext } from "../../hooks/useNftQrFormContext";
import { nftPreviewBoxStyles } from "./NftPreviews.styles";

interface NftPreviewsComponentProps {
  data: OwnedNftsResponse;
  onNext: () => void;
}
const NftPreviewsComponent = ({
  data,
  onNext,
}: NftPreviewsComponentProps): ReactElement => {
  const { setValue } = useNftQrFormContext();

  return (
    <Box css={nftPreviewBoxStyles}>
      {data.ownedNfts.map((nft) => {
        return (
          <NftPreview
            nft={nft}
            key={`${nft.contract.address}/${nft.tokenId}`}
            onClick={() => {
              setValue("nft", nft);
              onNext();
            }}
          />
        );
      })}
    </Box>
  );
};
export default NftPreviewsComponent;
  • react query를 사용한 예제이다.
  • react query는 10월 초 출시된 v5부터 SuspenseError Boundary를 이용한 선언형 컴포넌트를 정식 지원한다.
  • 기존 코드는 error 처리 부분이 빠져있다.

변경 코드

// SelectNftStep.tsx
interface SelectNftStepProps {
  address: string;
  onNext: () => void;
}

export const SelectNftStep = ({
  address,
  onNext,
}: SelectNftStepProps): ReactElement => {
  const [network, setNetwork] = useState<Network>(Network.ETH_MAINNET);

  return (
    <Box css={pageContentStyles}>
      <Box
        css={css`
          display: flex;
          flex-direction: row;
          align-items: center;
          gap: 4px;
        `}
      >
        <Typography variant="h4">Select NFT</Typography>
        <NetworkSelect network={network} onNetworkChange={setNetwork} />
      </Box>

      <QueryErrorResetBoundary>
        {({ reset }) => (
          <ErrorBoundary onReset={reset} fallbackRender={AppError}>
            <Suspense fallback={<NftPreviews.Skeleton />}>
              <NftPreviews.Component
                network={network}
                address={address}
                onNext={onNext}
              />
            </Suspense>
          </ErrorBoundary>
        )}
      </QueryErrorResetBoundary>
    </Box>
  );
};

const AppError = ({ error, resetErrorBoundary }: FallbackProps) => {
  return (
    <Alert severity="error">
      <AlertTitle>Error:</AlertTitle>
      {error.message}
      <Button onClick={resetErrorBoundary} color="success">
        <RestartAltIcon />
        Try again
      </Button>
    </Alert>
  );
};
// NftPreviews.component.tsx
interface NftPreviewsComponentProps {
  network: Network;
  address: string;
  onNext: () => void;
}
const NftPreviewsComponent = ({
  network,
  address,
  onNext,
}: NftPreviewsComponentProps): ReactElement => {
  const { data } = useSuspenseQuery({
    queryKey: ["nfts", address, network],
    queryFn: async () => {
      if (address == null) return;
      const alchemy = getAlchemy(network);
      return await alchemy.nft.getNftsForOwner(address);
    },
    staleTime: 1000 * 60 * 5, // 5 minutes
  });
  const { setValue } = useNftQrFormContext();

  return (
    <Box css={nftPreviewBoxStyles}>
      {data?.ownedNfts.map((nft) => {
        return (
          <NftPreview
            nft={nft}
            key={`${nft.contract.address}/${nft.tokenId}`}
            onClick={() => {
              setValue("nft", nft);
              onNext();
            }}
          />
        );
      })}
    </Box>
  );
};

export default NftPreviewsComponent;
  • react query v5에서 추가된 useSuspenseQuery를 사용했다.
  • useSuspenseQueryuseQuery에서 일부 옵션 및 결과가 바뀐다.

개인적인 감상

  • 확실하게 직관적이기는 하다.
  • 이제 useSuspenseQuery를 사용하는 내부에서는 data에 대해 분기처리 없이 data가 있다고 가정하고 코드를 작성 가능하다.

    내가 사용한 v5.0.5에서는 아직 불완전한지, data가 nullable로 타입 지정이 되있어 null check를 붙여주었다.

  • 복잡한 UI에서는 더 효과가 클 수 있지만, 내가 작성하는 앱은 단순해서 큰 차이를 보기는 힘들었다.
  • QueryErrorResetBoundary를 이용해 재시도의 상태를 일괄적으로 관리 가능한 부분은 매우 마음에 들었다.

예제 2

기존 코드

export const ConnectStep = (): ReactElement => {
  const { isConnected } = useAccount();
  const { connect, isLoading, error } = useConnect({
    connector: new MetaMaskConnector(),
  });
  return (
    <Box css={pageContentStyles}>
      {!isConnected && (
        <LoadingButton
          onClick={() => {
            connect();
          }}
          variant="contained"
          color="primary"
          loading={isLoading}
        >
          connect
        </LoadingButton>
      )}
      {error && (
        <Alert severity="error">
          <AlertTitle>Error:</AlertTitle>
          {error.message}
        </Alert>
      )}
    </Box>
  );
};

wagmi는 블록체인 관련 라이브러리로, 블록체인 지갑과 앱을 연결하는 역활을 담당한다. 내부적으로 react-query를 사용해 쉽게 이해가 가능하다.
이 코드를 Error boundary, Suspense를 사용해 선언형으로 바꿔보려고 했다.

수정 코드

export const ConnectStep = (): ReactElement => {
  const { reset, connect, isLoading, error } = useConnect({
    connector: new MetaMaskConnector(),
  });
  return (
    <Box css={pageContentStyles}>
      <ErrorBoundary onReset={reset} fallbackRender={AppError}>
        <ConnectButton connect={connect} isLoading={isLoading} error={error} />
      </ErrorBoundary>
    </Box>
  );
};

interface ConnectButtonProps {
  connect: () => void;
  isLoading: boolean;
  error: Error | null;
}
const ConnectButton = ({ connect, isLoading, error }: ConnectButtonProps) => {
  const { isConnected } = useAccount();

  if (error) {
    throw error;
  }
  if (isConnected) return null;

  return (
    <LoadingButton
      onClick={() => {
        connect();
      }}
      variant="contained"
      color="primary"
      loading={isLoading}
    >
      connect
    </LoadingButton>
  );
};
  • useConnect는 내부적으로 react query v4를 사용하고, suspense 관련 옵션 커스텀이 불가능해, 아래처럼 래핑해주었다.
  • ConnectButton에서 던지는 에러를 ErrorBoundary에서 캐치해 fallback을 표시한다.

참고 자료

카카오의 동시성 패턴 이야기

https://tech.kakaopay.com/post/react-query-2/
https://fe-developers.kakaoent.com/2022/221110-error-boundary/

react suspense 공식 문서

https://react.dev/reference/react/Suspense

react query suspense 공식 문서

https://tanstack.com/query/latest/docs/react/guides/suspense#resetting-error-boundaries

profile
개발자 지망생입니다.

0개의 댓글