# React 컴포넌트 패턴 (Frontend Component Patterns) 이 프로젝트의 React/Next.js 컴포넌트 패턴입니다. ## shadcn/ui 기반 컴포넌트 ### 설치 및 초기화 ```bash # shadcn/ui 초기화 npx shadcn@latest init # 컴포넌트 추가 npx shadcn@latest add button card dialog tabs table form ``` ### Button 컴포넌트 (CVA 패턴) ```tsx import * as React from "react" import { Slot } from "@radix-ui/react-slot" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/90", destructive: "bg-destructive text-white hover:bg-destructive/90", outline: "border bg-background shadow-xs hover:bg-accent", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-9 px-4 py-2", sm: "h-8 rounded-md px-3", lg: "h-10 rounded-md px-6", icon: "size-9", }, }, defaultVariants: { variant: "default", size: "default", }, } ) function Button({ className, variant, size, asChild = false, ...props }: React.ComponentProps<"button"> & VariantProps & { asChild?: boolean }) { const Comp = asChild ? Slot : "button" return ( ) } export { Button, buttonVariants } ``` ## Next.js App Router 구조 ### 레이아웃 (layout.tsx) ```tsx import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { Providers } from "@/components/providers"; import { Toaster } from "@/components/ui/sonner"; const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"], }); export const metadata: Metadata = { title: "News Engine Admin", description: "Admin dashboard for News Pipeline management", }; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { return ( {children} ); } ``` ### Provider 패턴 ```tsx // components/providers.tsx "use client" import { ThemeProvider } from "next-themes" export function Providers({ children }: { children: React.ReactNode }) { return ( {children} ) } ``` ## 유틸리티 함수 ### cn() 함수 (lib/utils.ts) ```tsx import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } ``` ## 커스텀 훅 패턴 ### 데이터 페칭 훅 ```tsx // hooks/use-articles.ts import { useState, useEffect } from "react" interface Article { id: string title: string summary: string } export function useArticles() { const [articles, setArticles] = useState([]) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { async function fetchArticles() { try { const res = await fetch("/api/articles") if (!res.ok) throw new Error("Failed to fetch") const data = await res.json() setArticles(data.items) } catch (e) { setError(e as Error) } finally { setIsLoading(false) } } fetchArticles() }, []) return { articles, isLoading, error } } ``` ### 토글 훅 ```tsx // hooks/use-toggle.ts import { useState, useCallback } from "react" export function useToggle(initialState = false) { const [state, setState] = useState(initialState) const toggle = useCallback(() => setState((s) => !s), []) const setTrue = useCallback(() => setState(true), []) const setFalse = useCallback(() => setState(false), []) return { state, toggle, setTrue, setFalse } } ``` ## 폼 패턴 (react-hook-form + zod) ```tsx "use client" import { useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { z } from "zod" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" const formSchema = z.object({ title: z.string().min(1, "제목을 입력하세요"), content: z.string().min(10, "내용은 10자 이상이어야 합니다"), }) type FormValues = z.infer export function ArticleForm() { const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { title: "", content: "", }, }) async function onSubmit(values: FormValues) { try { const res = await fetch("/api/articles", { method: "POST", body: JSON.stringify(values), }) if (!res.ok) throw new Error("Failed to create") // 성공 처리 } catch (error) { console.error(error) } } return (
( 제목 )} /> ) } ``` ## 다크모드 지원 ### 테마 토글 버튼 ```tsx "use client" import { useTheme } from "next-themes" import { Moon, Sun } from "lucide-react" import { Button } from "@/components/ui/button" export function ThemeToggle() { const { theme, setTheme } = useTheme() return ( ) } ``` ## 토스트 알림 (sonner) ```tsx import { toast } from "sonner" // 성공 알림 toast.success("저장되었습니다") // 에러 알림 toast.error("오류가 발생했습니다") // 로딩 알림 const toastId = toast.loading("처리 중...") // 완료 후 toast.success("완료!", { id: toastId }) ``` ## 파일 구조 ``` src/ ├── app/ # Next.js App Router │ ├── layout.tsx # 루트 레이아웃 │ ├── page.tsx # 메인 페이지 │ └── dashboard/ │ └── page.tsx ├── components/ │ ├── ui/ # shadcn/ui 기본 컴포넌트 │ │ ├── button.tsx │ │ ├── card.tsx │ │ └── ... │ ├── providers.tsx # Context Providers │ └── app-sidebar.tsx # 앱 전용 컴포넌트 ├── hooks/ # 커스텀 훅 │ └── use-articles.ts ├── lib/ # 유틸리티 │ └── utils.ts └── types/ # TypeScript 타입 └── index.ts ```