https://www.youtube.com/watch?v=ED2H_y6dmC8
07:34:34
Create UI components → UI 컴포넌트 생성
- Loading → 로딩
- Error → 에러(오류)
- Empty → 비어 있음(데이터 없음)
- List → 목록 컴포넌트
- Item → 개별 항목 (날짜 표시 + 삭제 기능)
1. Loading 컴포넌트
1-1. LoadingView 생성
src/components/entity-components.tsx
- Loader2Icon: 회전 애니메이션이 있는 로딩 스피너
- animate-spin: Tailwind CSS 회전 애니메이션 클래스
- !!message: 메시지가 있을 때만 표시 (옵셔널)
interface StateViewProps{
message?:string;
}
export const LoadingView = ({
message,
}:StateViewProps) => {
return(
<div className="flex justify-center items-center h-full flex-1 flex-col gap-y-4">
<Loader2Icon className="size-6 animate-spin text-primary"/>
{!!message && (
<p className="text-sm text-muted-foreground">
{message}
</p>
)}
</div>
)
}
1-2. WorkflowsLoading 생성
src/features/workflows/components/workflows.tsx
import { LoadingView } from "@/components/entity-components";
export const WorkflowsLoading = () => {
return <LoadingView message="Loading workflows..."/>
}
1-3. 페이지에 적용
src/app/(dashboard)/(rest)/workflows/page.tsx
- Suspense fallback: 데이터 로딩 중에 표시될 컴포넌트를 지정
import { WorkflowsContainer, WorkflowsList, WorkflowsLoading } from "@/features/workflows/components/workflows";
...
type Props = {
searchParams: Promise<SearchParams>
}
const Page = async({searchParams}:Props) => {
await requireAuth()
const params = await workflowsParamsLoader(searchParams)
prefetchWorkflows(params)
return(
<WorkflowsContainer>
<HydrateClient>
<ErrorBoundary fallback={<div>Error!</div>}>
{/* <Suspense fallback={<p>Loading...</p>}> */}
<Suspense fallback={<WorkflowsLoading/>}>
<WorkflowsList/>
</Suspense>
</ErrorBoundary>
</HydrateClient>
</WorkflowsContainer>
)
}
export default Page;
2. Error 컴포넌트
2-1. ErrorView 생성
src/components/entity-components.tsx
- AlertTriangleIcon: 경고 아이콘 사용
export const ErrorView = ({
message,
}:StateViewProps) => {
return(
<div className="flex justify-center items-center h-full flex-1 flex-col gap-y-4">
<AlertTriangleIcon className="size-6 animate-spin text-primary"/>
{!!message && (
<p className="text-sm text-muted-foreground">
{message}
</p>
)}
</div>
)
}
2-2. WorkflowsError 생성
src/features/workflows/components/workflows.tsx
import { ErrorView } from "@/components/entity-components";
export const WorkflowsError = () => {
return <ErrorView message="Error loading workflows..."/>
}
2-3. 페이지에 적용
src/app/(dashboard)/(rest)/workflows/page.tsx
import { WorkflowsError} from "@/features/workflows/components/workflows";
...
type Props = {
searchParams: Promise<SearchParams>
}
const Page = async({searchParams}:Props) => {
await requireAuth()
const params = await workflowsParamsLoader(searchParams)
prefetchWorkflows(params)
return(
<WorkflowsContainer>
<HydrateClient>
<ErrorBoundary fallback={<WorkflowsError/>}>
{/* <Suspense fallback={<p>Loading...</p>}> */}
<Suspense fallback={<WorkflowsLoading/>}>
<WorkflowsList/>
</Suspense>
</ErrorBoundary>
</HydrateClient>
</WorkflowsContainer>
)
}
export default Page;
2-4. 에러 테스트
- 테스트 후 삭제: 에러가 제대로 표시되는지 확인한 후 throw new Error("test") 라인을 삭제
src/features/workflows/components/workflows.tsx
export const WorkflowsList = () => {
throw new Error("test")
const workflows = useSuspenseWorkflows();
return(
<div className="flex-1 flex justify-center items-center">
<p>
{JSON.stringify(workflows.data, null, 2)}
</p>
</div>
)
}

3. Empty 컴포넌트
3-1. shadcn/ui Empty 컴포넌트 설치
공식 문서를 참고하여 Empty 컴포넌트를 설치
3-2. EmptyView 생성
- Empty: shadcn/ui의 Empty 컨테이너
- EmptyMedia: 아이콘 영역 (PackageOpenIcon)
- EmptyTitle: 제목 ("No Items")
- EmptyDescription: 설명 메시지 (옵셔널)
- EmptyContent: 액션 버튼 영역 (onNew가 있을 때만 표시)
src/components/entity-components.tsx
interface EmptyViewProps extends StateViewProps{
onNew?: ()=> void
}
export const EmptyView = ({
message,
onNew
}:EmptyViewProps) =>{
return(
<Empty className="border border-dashed bg-white">
<EmptyHeader>
<EmptyMedia variant="icon">
<PackageOpenIcon/>
</EmptyMedia>
</EmptyHeader>
<EmptyTitle>
No Items
</EmptyTitle>
{!!message && (
<EmptyDescription>
{message}
</EmptyDescription>
)}
{!!onNew && (
<EmptyContent>
<Button onClick={onNew}>
Add item
</Button>
</EmptyContent>
)}
</Empty>
)
}
3-3. WorkflowsEmpty 생성
- "Add item" 버튼 클릭
- handleCreate 실행
- 성공 시 → 생성된 워크플로우 페이지로 이동
- 실패 시 (구독 필요) → Upgrade Modal 표시
src/features/workflows/components/workflows.tsx
export const WorkflowsEmpty = () => {
const router = useRouter()
const createWorkflow = useCreateWorkflow();
const { handleError, modal} = useUpgradeModal();
const handleCreate = () => {
createWorkflow.mutate(undefined, {
onError: (error) => {
handleError(error);
},
onSuccess: (data) => {
router.push(`/workflows/${data.id}`)
}
})
}
return(
<>
{modal}
<EmptyView
onNew={()=>handleCreate}
message="You haven't created any workflows yet. Get started by createing a workflow"
/>
</>
)
}
3-4. WorkflowsList에 Empty 상태 처리
src/features/workflows/components/workflows.tsx 파일 수정
export const WorkflowsList = () => {
// throw new Error("test")
const workflows = useSuspenseWorkflows();
if(workflows.data.items.length === 0) {
return(
<WorkflowsEmpty/>
)
}
return(
<div className="flex-1 flex justify-center items-center">
<p>
{JSON.stringify(workflows.data, null, 2)}
</p>
</div>
)
}

4. EntityList 컴포넌트 (제네릭 목록)
4-1. EntityList 생성
- <T>: 어떤 타입의 데이터든 처리 가능 (재사용성 극대화)
- renderItem: 각 아이템을 어떻게 렌더링할지 외부에서 결정
- getKey: 고유 key 생성 방법 커스터마이징
- emptyView: 빈 상태 UI 커스터마이징
src/components/entity-components.tsx
export function EntityList<T>({
items,
renderItem,
getKey,
emptyView,
className,
}:EntityListProps<T>){
if(items.length === 0 && emptyView){
return(
<div className="flex-1 flex justify-center items-center">
<div className="max-w-sm mx-auto">{emptyView}</div>
</div>
)
}
return(
<div className={cn(
"flex flex-col gap-y-4",
className,
)}>
{items.map((item, index) => (
<div key={getKey ? getKey(item, index) : index}>
{renderItem(item, index)}
</div>
))}
</div>
)
}
4-2. WorkflowsList에 EntityList 적용
src/features/workflows/components/workflows.tsx
export const WorkflowsList = () => {
const workflows = useSuspenseWorkflows();
// if(workflows.data.items.length === 0) {
// return(
// <WorkflowsEmpty/>
// )
// }
// return(
// <div className="flex-1 flex justify-center items-center">
// <p>
// {JSON.stringify(workflows.data, null, 2)}
// </p>
// </div>
// )
return(
<EntityList
items={workflows.data.items}
getKey={(workflow) => workflow.id}
renderItem={(workflow) => <p>{workflow.name}</p>}
emptyView={<WorkflowsEmpty/>}
/>
)
}


5. EntityItem 컴포넌트 (개별 항목)
5-1. EntityItem 생성
- 클릭 가능한 카드: 전체 카드가 Link로 감싸져 페이지 이동
- 호버 효과: hover:shadow로 마우스 오버 시 그림자 효과
- 삭제 중 상태: isRemoving일 때 반투명 + 클릭 비활성화
- 이벤트 버블링 방지:
- e.stopPropagation(): 버튼 클릭이 카드 클릭으로 전파되지 않도록
- e.preventDefault(): 기본 동작 방지
src/components/entity-components.tsx
interface EntityItemProps{
href: string;
title: string;
subtitle?: React.ReactNode;
image?: React.ReactNode;
actions?: React.ReactNode;
onRemove?: () => void | Promise<void>;
isRemoving?: boolean;
className?: string;
}
export const EntityItem = ({
href,
title,
subtitle,
image,
actions,
onRemove,
isRemoving,
className,
}:EntityItemProps) => {
const handleRemove = async (e:React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if(isRemoving){
return;
}
if(onRemove) {
return onRemove()
}
}
return(
<Link href={href} prefetch>
<Card
className={cn(
"p-4 shadow-none hover:shadow cursor-pointer",
isRemoving && "opacity-50 cursor-not-allowed",
className,
)}
>
<CardContent className="flex flex-row items-center justify-between p-0">
<div className="flex items-center gap-3">
{image}
<div>
<CardTitle className="text-base font-medium">
{title}
</CardTitle>
{!!subtitle && (
<CardDescription className="text-xs">
{subtitle}
</CardDescription>
)}
</div>
</div>
{(actions || onRemove) && (
<div className="flex gap-x-4 items-center">
{actions}
{onRemove && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
onClick={(e)=> e.stopPropagation()}
>
<MoreVerticalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem onClick={handleRemove}>
<TrashIcon className="size-4"/>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
</CardContent>
</Card>
</Link>
)
}
6. Superjson 설정 (날짜 직렬화)
6-1. Superjson 설치
💡 Superjson이 필요한 이유
tRPC는 기본적으로 JSON만 직렬화한다.
Date 객체는 JSON으로 변환 시 문자열이 되어 타입 정보를 잃게 된다.
Superjson은 Date, Map, Set 등을 올바르게 직렬화/역직렬화한다.
npm i superjson
6-2. tRPC 서버 설정
src/trpc/init.ts
import superjson from 'superjson'
export const createTRPCContext = cache(async () => {
/**
* @see: https://trpc.io/docs/server/context
*/
return { userId: 'user_123' };
});
// Avoid exporting the entire t-object
// since it's not very descriptive.
// For instance, the use of a t variable
// is common in i18n libraries.
const t = initTRPC.create({
/**
* @see https://trpc.io/docs/server/data-transformers
*/
transformer: superjson,
});
6-3. Query Client 설정
src/trpc/query-client.ts
import {
defaultShouldDehydrateQuery,
QueryClient,
} from '@tanstack/react-query';
import superjson from 'superjson';
export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
},
dehydrate: {
serializeData: superjson.serialize,
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === 'pending',
},
hydrate: {
deserializeData: superjson.deserialize,
},
},
});
}
6-4. tRPC Client 설정
src/trpc/client.tsx
createTRPCClient<AppRouter>({
links: [
httpBatchLink({
// transformer: superjson, <-- if you use a data transformer
transformer: superjson
url: getUrl(),
}),
],
}),
src/features/workflows/components/workflows.tsx
- renderItem={(workflow) => <WorkflowItem data={workflow}/>}
export const WorkflowsList = () => {
const workflows = useSuspenseWorkflows();
return(
<EntityList
items={workflows.data.items}
getKey={(workflow) => workflow.id}
// renderItem={(workflow) => <p>{workflow.name}</p>}
renderItem={(workflow) => <WorkflowItem data={workflow}/>}
emptyView={<WorkflowsEmpty/>}
/>
)
}
Superjson 없이 날짜 에러가 발생한 이유:
- Superjson 없이는 Date 객체가 문자열로 전송됨
- formatDistanceToNow는 Date 객체를 받아야 하는데 문자열을 받아 에러 발생
- Superjson 설정 후 Date 객체가 올바르게 복원되어 해결
Superjson 작동 방식:
서버 (Prisma) → Date 객체
↓ (superjson.serialize)
JSON + 타입 메타데이터
↓ (네트워크 전송)
JSON + 타입 메타데이터
↓ (superjson.deserialize)
클라이언트 → Date 객체 복원
아직 날짜는 나오지 않는상태.

7. 날짜 포맷팅
7-1. date-fns 설치
💡 date-fns: 날짜 포맷팅을 위한 경량 라이브러리.
"3 hours ago", "2 days ago" 같은 상대 시간 표시에 유용하다.
npm i date-fn
7-2. WorkflowItem 컴포넌트 생성
- formatDistanceToNow(date, { addSuffix: true }): "3 hours ago" 형식
- •: HTML 불릿 포인트 (•)
- Superjson 덕분에 data.updatedAt과 data.createdAt이 Date 객체로 유지됨
import { formatDistanceToNow } from "date-fns"
// renderItem
export const WorkflowItem = ({data} : {data: Workflow}) => {
return(
<EntityItem
href={`/workflows/${data.id}`}
title={data.name}
subtitle={
<>
Updated {formatDistanceToNow(data.updatedAt, { addSuffix: true})}{" "}
• Created{" "}
Updated {formatDistanceToNow(data.createdAt, { addSuffix: true})}{" "}
</>
}
image={
<div className="size-8 flex items-center justify-center">
<WorkflowIcon className="size-5 text-muted-foreground"/>
</div>
}
onRemove={()=>{}}
isRemoving={false}
/>
)
}

8. 삭제 기능 구현
8-1. Remove Hook 생성
- getMany: 목록 페이지의 데이터 새로고침
- getOne: 상세 페이지 데이터 제거 (이미 삭제된 워크플로우)
src/features/workflows/hooks/use-workflows.ts
export const useRemoveWorkflow = () => {
const trpc = useTRPC();
const queryClient = useQueryClient();
return useMutation(
trpc.workflows.remove.mutationOptions({
onSuccess: (data) => {
toast.success(`Workflow "${data.name}" removed`);
queryClient.invalidateQueries(trpc.workflows.getMany.queryOptions({}));
queryClient.invalidateQueries(
trpc.workflows.getOne.queryFilter({id:data.id})
);
}
})
)
}
8-2. WorkflowItem에 삭제 기능 추가
- MoreVertical 아이콘 (⋮) 클릭
- 드롭다운 메뉴 표시
- "Delete" 클릭
- handleRemove 실행
- API 호출 중 → isRemoving={true} (반투명 + 비활성화)
- 성공 → 토스트 메시지 + 목록에서 제거
src/features/workflows/components/workflows.tsx
// renderItem
export const WorkflowItem = ({data} : {data: Workflow}) => {
const removeWorkflow = useRemoveWorkflow();
const handleRemove = () => {
removeWorkflow.mutate({id: data.id})
}
return(
<EntityItem
href={`/workflows/${data.id}`}
title={data.name}
subtitle={
<>
Updated {formatDistanceToNow(data.updatedAt, { addSuffix: true})}{" "}
• Created{" "}
Updated {formatDistanceToNow(data.createdAt, { addSuffix: true})}{" "}
</>
}
image={
<div className="size-8 flex items-center justify-center">
<WorkflowIcon className="size-5 text-muted-foreground"/>
</div>
}
onRemove={handleRemove}
isRemoving={removeWorkflow.isPending}
/>
)
}
'Clone Coding' 카테고리의 다른 글
| N8N & Zapier - Editor Setup (1) | 2026.01.20 |
|---|---|
| N8N & Zapier - Workflow Page (0) | 2026.01.17 |
| N8N & Zapier - Workflows Pagination (0) | 2026.01.15 |
| N8N & Zapier - Workflows Crud / 2 (0) | 2026.01.13 |
| N8N & Zapier - Workflows Crud / 1 (0) | 2026.01.12 |