https://www.youtube.com/watch?v=ED2H_y6dmC8
08:23:40
전체 공부내용
Load workflow page by ID → ID로 워크플로우 페이지 불러오기
- Prefetch → 사전 가져오기
- useSuspenseQuery → useSuspenseQuery 사용
- Loading → 로딩
- Error → 오류
Create "WorkflowHeader" component → "WorkflowHeader" 컴포넌트 생성
- Update workflow name → 워크플로우 이름 업데이트
Create "Editor" component → "Editor" 컴포넌트 생성
Add react-flow → react-flow 추가
Prefetch
- Next.js의 서버 컴포넌트에서 tRPC + React Query 데이터를 먼저 가져오는 단계.
- [페이지 접속 → 클라이언트 JS 로드 → 데이터 요청 → 로딩 표시 → 완료] 과정에서
- “깜빡이는 로딩 화면” 없이 렌더링
- 클라이언트에서 다시 호출하지 않아도 됨 (워터폴 방지)
1. Prefetch 함수 정의
src/features/workflows/server/prefetch.ts
- 서버에서 미리 데이터를 가져오는 명령을 정의
/*
prefetch a single workflow
*/
export const prefetchWorkflow = (id: string) => {
return prefetch(trpc.workflows.getOne.queryOptions({ id }))
}
2. 클라이언트 Hook 정의
src/features/workflows/hooks/use-workflows.ts
- useSuspenseQuery는 데이터가 없으면 로딩 상태를 던지고, 있으면 바로 반환
export const useSuspenseWorkflow = (id:string) => {
const trpc = useTRPC();
return useSuspenseQuery(trpc.workflows.getOne.queryOptions({id}))
}
3. 페이지 컴포넌트에서 Prefetch 실행
src/app/(dashboard)/(editor)/workflows/[workflowId]/page.tsx
- 서버 컴포넌트에서 페이지가 렌더링되기 전에 prefetchWorkflow를 실행해서 데이터를 미리 확보
- 서버에서 데이터를 Prefetch 하더라도, 그 데이터를 클라이언트의 React Query가 전달받으려면 tRPC설정에 따라 HydrateClient로 컴포넌트를 감싸야 한다.
import { Editor, EditorError, EditorLoading } from "@/features/editor/components/editor";
import { prefetchWorkflow } from "@/features/workflows/server/prefetch";
import { requireAuth } from "@/lib/auth-utils";
import { HydrateClient } from "@/trpc/server";
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
interface PageProps{
params : Promise<{
workflowId: string;
}>
}
const Page = async({params}:PageProps) => {
await requireAuth()
const {workflowId} = await params;
// 1. 데이터를 미리 확보
prefetchWorkflow(workflowId)
return (
// <p>workflow id: {workflowId}</p>
// 2. 서버에서 가져온 데이터를 클라이언트 QueryClient에 주입하는 래퍼가 필요함
<HydrateClient>
// 3. 4번에서 에디터 컴포넌트 구현 예정
<ErrorBoundary fallback={<EditorError/>}>
<Suspense fallback={<EditorLoading/>}>
<Editor workflowId={workflowId}/>
</Suspense>
</ErrorBoundary>
</HydrateClient>
)
}
export default Page
4. 에디터 컴포넌트 구현
src/features/editor/components/editor.tsx
- Prefetch된 데이터를 즉시 사용할 수 있다.
"use client"
import { ErrorView, LoadingView } from "@/components/entity-components"
import { useSuspenseWorkflow } from "@/features/workflows/hooks/use-workflows"
export const EditorLoading = () => {
return <LoadingView message="Loading editor..."/>
}
export const EditorError = () => {
return <ErrorView message="Error loading editor..."/>
}
export const Editor = ({workflowId}:{workflowId: string}) => {
const {data: workflow} = useSuspenseWorkflow(workflowId)
return(
<p>
{JSON.stringify(workflow, null, 2)}
</p>
)
}
http://localhost:3000/workflows/cmkglyeuj0009orao3hegme15

워크플로우 이름 업데이트 기능
Mutation Hook 구현
src/features/workflows/hooks/use-workflows.ts
export const useUpdateWorkflowName = () => {
const queryClient = useQueryClient();
const trpc = useTRPC();
return useMutation(trpc.workflows.updateName.mutationOptions({
onSuccess: (data) => {
toast.success(`Workflow "${data.name}" updated`)
// 캐시 무효화로 최신 데이터 반영
queryClient.invalidateQueries(
trpc.workflows.getMany.queryOptions({})
);
queryClient.invalidateQueries(
trpc.workflows.getOne.queryOptions({id: data.id}),
);
},
onError: (error) => {
toast.error(`Failed to update workflow: ${error.message}`)
}
}))
}
에디터 헤더 구현
src/features/editor/components/editor-header.tsx
- 워크플로우 이름을 클릭하면 편집 모드로 전환되는 인터랙티브한 UI를 구현
- Enter 키로 저장, Escape 키로 취소
- 포커스 아웃 시 자동 저장
- 편집 모드 진입 시 텍스트 전체 선택
- 변경사항이 없으면 저장 생략
"use client"
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { SidebarTrigger } from "@/components/ui/sidebar"
import { useSuspenseWorkflow, useUpdateWorkflowName } from "@/features/workflows/hooks/use-workflows"
import { SaveIcon } from "lucide-react"
import Link from "next/link"
import { useEffect, useRef, useState } from "react"
export const EditorSaveButton =({workflowId} : {workflowId:string}) => {
return(
<div className="ml-auto">
<Button size="sm" onClick={()=>{}} disabled={false}>
<SaveIcon className="size-4"/>
Save
</Button>
</div>
)
}
export const EditorNameInput = ({workflowId} : {workflowId: string}) => {
const {data: workflow} = useSuspenseWorkflow(workflowId)
const updateWorkflow = useUpdateWorkflowName()
const [isEditing, setIsEditing] = useState(false)
const [name, setName] = useState(workflow.name)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(()=>{
if(workflow.name){
setName(workflow.name);
}
},[workflow.name])
useEffect(()=>{
if(isEditing && inputRef.current){
inputRef.current.focus();
inputRef.current.select();
}
},[isEditing])
const handleSave = async() => {
if(name === workflow.name){
setIsEditing(false);
return;
}
try{
await updateWorkflow.mutateAsync({
id: workflowId,
name,
})
}catch{
setName(workflow.name)
}finally{
setIsEditing(false)
}
}
const handleKeyDown = (e:React.KeyboardEvent) => {
if(e.key === "Enter"){
handleSave();
}else if(e.key === "Escape"){
setName(workflow.name);
setIsEditing(false)
}
}
if(isEditing){
return(
<Input
disabled={updateWorkflow.isPending}
ref={inputRef}
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
className="h-7 w-auto min-w-[100px] px-2"
/>
)
}
return(
<BreadcrumbItem
onClick={()=> setIsEditing(true)}
className="currsor-pointer hover:text-foreground transition-colors"
>
{workflow.name}
</BreadcrumbItem>
)
}
export const EditorBreadcrumbs =({workflowId} : {workflowId:string}) => {
return(
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link prefetch href="/workflows">
Workflows
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator/>
<EditorNameInput workflowId={workflowId}/>
</BreadcrumbList>
</Breadcrumb>
)
}
export const EditorHeader = ({workflowId} : {workflowId: string}) => {
return(
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4 bg-background">
<SidebarTrigger/>
<div className="flex flex-row items-center justify-between gap-x-4 w-full">
<EditorBreadcrumbs workflowId={workflowId}/>
<EditorSaveButton workflowId={workflowId}/>
</div>
</header>
)
}
* 타입 안정성 개선
workflow.name의 타입이 string | null이어서 타입 에러가 발생
해결 방법
- findUnique 대신 findUniqueOrThrow를 사용:
- 데이터가 없을 경우 에러를 발생시킴
- 반환 타입이 null을 포함하지 않게 됨
- 타입 안정성 확보
src/features/workflows/server/routers.ts
// getOne : protectedProcedure
// .input(z.object({id: z.string()}))
// .query(({ctx, input}) => {
// return prisma.workflow.findUnique({
// where: { id: input.id, userId: ctx.auth.user.id}
// })
// }),
getOne : protectedProcedure
.input(z.object({id: z.string()}))
.query(({ctx, input}) => {
return prisma.workflow.findUniqueOrThrow({
where: { id: input.id, userId: ctx.auth.user.id}
})
}),
src/app/(dashboard)/(editor)/workflows/[workflowId]/page.tsx
import { Editor, EditorError, EditorLoading } from "@/features/editor/components/editor";
import { prefetchWorkflow } from "@/features/workflows/server/prefetch";
import { requireAuth } from "@/lib/auth-utils";
import { HydrateClient } from "@/trpc/server";
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
interface PageProps{
params : Promise<{
workflowId: string;
}>
}
const Page = async({params}:PageProps) => {
await requireAuth()
const {workflowId} = await params;
prefetchWorkflow(workflowId)
return (
// <p>workflow id: {workflowId}</p>
<HydrateClient>
<ErrorBoundary fallback={<EditorError/>}>
<Suspense fallback={<EditorLoading/>}>
<EditorHeader workflowId={workflowId}/>
<main className="flex-1">
<Editor workflowId={workflowId}/>
</main>
</Suspense>
</ErrorBoundary>
</HydrateClient>
)
}
export default Page

- Prefetch 패턴: 서버 컴포넌트에서 데이터를 미리 가져와 UX 개선
- HydrateClient: 서버 데이터를 클라이언트 React Query에 전달하는 핵심 래퍼
- useSuspenseQuery: Suspense와 함께 사용하여 로딩 상태 자동 처리
- Mutation + Cache Invalidation: 데이터 변경 후 캐시 무효화로 최신 상태 유지
- 타입 안정성: findUniqueOrThrow로 null 타입 제거
'Clone Coding' 카테고리의 다른 글
| N8N & Zapier - Node selector (0) | 2026.01.22 |
|---|---|
| N8N & Zapier - Editor Setup (1) | 2026.01.20 |
| N8N & Zapier - Workflows UI (1) | 2026.01.16 |
| N8N & Zapier - Workflows Pagination (0) | 2026.01.15 |
| N8N & Zapier - Workflows Crud / 2 (0) | 2026.01.13 |