Concurrent UI Pattern을 사용한 선언형 컴포넌트의 장점이 잘 와닿지 않았다.Concurrent UI Pattern을 사용하지 않은 컴포넌트를 「명령형 컴포넌트」로, Concurrent UI Pattern을 사용한 컴포넌트를 「선언형 컴포넌트」로 표현하고 있음을 인지하시고 읽어주세요.
카카오 기술 블로그 - React Query와 함께 Concurrent UI Pattern을 도입하는 방법
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부터 Suspense와 Error 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를 사용했다.useSuspenseQuery는 useQuery에서 일부 옵션 및 결과가 바뀐다.useSuspenseQuery를 사용하는 내부에서는 data에 대해 분기처리 없이 data가 있다고 가정하고 코드를 작성 가능하다.내가 사용한 v5.0.5에서는 아직 불완전한지, data가 nullable로 타입 지정이 되있어 null check를 붙여주었다.
QueryErrorResetBoundary를 이용해 재시도의 상태를 일괄적으로 관리 가능한 부분은 매우 마음에 들었다.
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/
https://react.dev/reference/react/Suspense
https://tanstack.com/query/latest/docs/react/guides/suspense#resetting-error-boundaries