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