7.7 KiB
7.7 KiB
React 컴포넌트 패턴 (Frontend Component Patterns)
이 프로젝트의 React/Next.js 컴포넌트 패턴입니다.
shadcn/ui 기반 컴포넌트
설치 및 초기화
# shadcn/ui 초기화
npx shadcn@latest init
# 컴포넌트 추가
npx shadcn@latest add button card dialog tabs table form
Button 컴포넌트 (CVA 패턴)
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<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
Next.js App Router 구조
레이아웃 (layout.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 (
<html lang="en">
<body className={`${geistSans.variable} antialiased`}>
<Providers>
{children}
<Toaster />
</Providers>
</body>
</html>
);
}
Provider 패턴
// components/providers.tsx
"use client"
import { ThemeProvider } from "next-themes"
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
)
}
유틸리티 함수
cn() 함수 (lib/utils.ts)
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
커스텀 훅 패턴
데이터 페칭 훅
// hooks/use-articles.ts
import { useState, useEffect } from "react"
interface Article {
id: string
title: string
summary: string
}
export function useArticles() {
const [articles, setArticles] = useState<Article[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<Error | null>(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 }
}
토글 훅
// 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)
"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<typeof formSchema>
export function ArticleForm() {
const form = useForm<FormValues>({
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 (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>제목</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">저장</Button>
</form>
</Form>
)
}
다크모드 지원
테마 토글 버튼
"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 (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
>
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
</Button>
)
}
토스트 알림 (sonner)
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