Communitygithub.com

ValentinCmbr/claude-skills

React, Next.js, 상태 관리, 성능 최적화 및 UI 모범 사례를 위한 프론트엔드 개발 패턴. React 또는 Next.js 컴포넌트, 상태, 렌더링 성능을 구축하거나 검토할 때 사용하세요.

claude-skills란 무엇인가요?

이 스킬은 ValentinCmbr/claude-skills 저장소를 기반으로 하며, 현대적인 프론트엔드 개발, 특히 React와 Next.js 애플리케이션에 초점을 맞춘 종합 패턴 및 모범 사례 모음입니다. 구성(Component Composition), 복합 컴포넌트(Compound Components), 렌더 프롭스(Render Props)와 같은 핵심 컴포넌트 패턴을 다루며, 확장 가능하고 재사용 가능한 UI를 구축하는 방법을 제시합니다. 또한 useState/useReducer를 사용한 상태 관리, SWR 및 React Query를 통한 데이터 가져오기, 성능 최적화를 위한 메모이제이션(memoization) 및 코드 분할(code splitting) 등 고급 패턴을 포함합니다. 이 스킬은 오픈 소스이며 Claude Code, Cursor 등 다양한 AI 코딩 에이전트와 함께 작동하도록 설계되어, 복잡한 프론트엔드 구현 중 개발자에게 일관된 지침을 제공합니다. 각 패턴에는 TypeScript 예제와 사용 방법이 포함되어 있어, AI가 React 생태계의 일반적인 과제에 대해 표준화된 솔루션을 제공할 수 있도록 보장합니다.

지원 대상✓Claude Code~Codex CLI~Cursor
npx skills add https://github.com/ValentinCmbr/claude-skills/tree/HEAD/plugins/skills/skills/frontend-patterns

즐겨 사용하는 AI에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

문서

프론트엔드 개발 패턴

React, Next.js 및 고성능 사용자 인터페이스를 위한 현대적인 프론트엔드 패턴입니다.

활성 시점

  • React 컴포넌트 구축 시 (컴포지션, props, 렌더링)
  • 상태 관리 시 (useState, useReducer, Zustand, Context)
  • 데이터 가져오기 구현 시 (SWR, React Query, 서버 컴포넌트)
  • 성능 최적화 시 (메모이제이션, 가상화, 코드 분할)
  • 폼 작업 시 (유효성 검사, 제어된 입력, Zod 스키마)
  • 클라이언트 사이드 라우팅 및 내비게이션 처리 시
  • 접근 가능하고 반응형인 UI 패턴 구축 시

컴포넌트 패턴

구성(Composition) 대 상속(Inheritance)

// PASS: 좋음: 컴포넌트 구성
interface CardProps {
  children: React.ReactNode
  variant?: 'default' | 'outlined'
}

export function Card({ children, variant = 'default' }: CardProps) {
  return <div className={`card card-${variant}`}>{children}</div>
}

export function CardHeader({ children }: { children: React.ReactNode }) {
  return <div className="card-header">{children}</div>
}

export function CardBody({ children }: { children: React.ReactNode }) {
  return <div className="card-body">{children}</div>
}

// 사용법
<Card>
  <CardHeader>제목</CardHeader>
  <CardBody>내용</CardBody>
</Card>

복합 컴포넌트

interface TabsContextValue {
  activeTab: string
  setActiveTab: (tab: string) => void
}

const TabsContext = createContext<TabsContextValue | undefined>(undefined)

export function Tabs({ children, defaultTab }: {
  children: React.ReactNode
  defaultTab: string
}) {
  const [activeTab, setActiveTab] = useState(defaultTab)

  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      {children}
    </TabsContext.Provider>
  )
}

export function TabList({ children }: { children: React.ReactNode }) {
  return <div className="tab-list">{children}</div>
}

export function Tab({ id, children }: { id: string, children: React.ReactNode }) {
  const context = useContext(TabsContext)
  if (!context) throw new Error('Tab은 Tabs 내에서 사용해야 합니다')

  return (
    <button
      className={context.activeTab === id ? 'active' : ''}
      onClick={() => context.setActiveTab(id)}
    >
      {children}
    </button>
  )
}

// 사용법
<Tabs defaultTab="overview">
  <TabList>
    <Tab id="overview">개요</Tab>
    <Tab id="details">세부사항</Tab>
  </TabList>
</Tabs>

렌더 프롭스 패턴

interface DataLoaderProps<T> {
  url: string
  children: (data: T | null, loading: boolean, error: Error | null) => React.ReactNode
}

export function DataLoader<T>({ url, children }: DataLoaderProps<T>) {
  const [data, setData] = useState<T | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<Error | null>(null)

  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false))
  }, [url])

  return <>{children(data, loading, error)}</>
}

// 사용법
<DataLoader<Market[]> url="/api/markets">
  {(markets, loading, error) => {
    if (loading) return <Spinner />
    if (error) return <Error error={error} />
    return <MarketList markets={markets!} />
  }}
</DataLoader>

커스텀 훅 패턴

상태 관리 훅

export function useToggle(initialValue = false): [boolean, () => void] {
  const [value, setValue] = useState(initialValue)

  const toggle = useCallback(() => {
    setValue(v => !v)
  }, [])

  return [value, toggle]
}

// 사용법
const [isOpen, toggleOpen] = useToggle()

비동기 데이터 가져오기 훅

interface UseQueryOptions<T> {
  onSuccess?: (data: T) => void
  onError?: (error: Error) => void
  enabled?: boolean
}

export function useQuery<T>(
  key: string,
  fetcher: () => Promise<T>,
  options?: UseQueryOptions<T>
) {
  const [data, setData] = useState<T | null>(null)
  const [error, setError] = useState<Error | null>(null)
  const [loading, setLoading] = useState(false)

  // 최신 fetcher/options를 ref에 유지하여, 호출자가 인라인 함수와 객체 리터럴을
  // 전달해도 refetch가 참조적으로 안정적이게 합니다.
  // 이렇게 하지 않으면, 렌더링할 때마다 새로운 refetch가 생성되고,
  // 아래 effect가 상태 업데이트 후에 다시 실행되어 무한 가져오기 루프가 발생합니다.
  const fetcherRef = useRef(fetcher)
  const optionsRef = useRef(options)
  useEffect(() => {
    fetcherRef.current = fetcher
    optionsRef.current = options
  })

  const refetch = useCallback(async () => {
    setLoading(true)
    setError(null)

    try {
      const result = await fetcherRef.current()
      setData(result)
      optionsRef.current?.onSuccess?.(result)
    } catch (err) {
      const error = err as Error
      setError(error)
      optionsRef.current?.onError?.(error)
    } finally {
      setLoading(false)
    }
  }, [])

  const enabled = options?.enabled !== false

  useEffect(() => {
    if (enabled) {
      refetch()
    }
  }, [key, enabled, refetch])

  return { data, error, loading, refetch }
}

// 사용법
const { data: markets, loading, error, refetch } = useQuery(
  'markets',
  () => fetch('/api/markets').then(r => r.json()),
  {
    onSuccess: data => console.log('가져옴', data.length, '개의 마켓'),
    onError: err => console.error('실패:', err)
  }
)

디바운스 훅

export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value)

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)

    return () => clearTimeout(handler)
  }, [value, delay])

  return debouncedValue
}

// 사용법
const [searchQuery, setSearchQuery] = useState('')
const debouncedQuery = useDebounce(searchQuery, 500)

useEffect(() => {
  if (debouncedQuery) {
    performSearch(debouncedQuery)
  }
}, [debouncedQuery])

상태 관리 패턴

Context + Reducer 패턴

interface State {
  markets: Market[]
  selectedMarket: Market | null
  loading: boolean
}

type Action =
  | { type: 'SET_MARKETS'; payload: Market[] }
  | { type: 'SELECT_MARKET'; payload: Market }
  | { type: 'SET_LOADING'; payload: boolean }

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'SET_MARKETS':
      return { ...state, markets: action.payload }
    case 'SELECT_MARKET':
      return { ...state, selectedMarket: action.payload }
    case 'SET_LOADING':
      return { ...state, loading: action.payload }
    default:
      return state
  }
}

const MarketContext = createContext<{
  state: State
  dispatch: Dispatch<Action>
} | undefined>(undefined)

export function MarketProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(reducer, {
    markets: [],
    selectedMarket: null,
    loading: false
  })

  return (
    <MarketContext.Provider value={{ state, dispatch }}>
      {children}
    </MarketContext.Provider>
  )
}

export function useMarkets() {
  const context = useContext(MarketContext)
  if (!context) throw new Error('useMarkets는 MarketProvider 내에서 사용해야 합니다')
  return context
}

성능 최적화

메모이제이션

// PASS: 값비싼 계산에는 useMemo 사용
// 정렬 전에 복사 - Array.prototype.sort는 원본을 변경합니다
const sortedMarkets = useMemo(() => {
  return [...markets].sort((a, b) => b.volume - a.volume)
}, [markets])

// PASS: 자식에게 전달되는 함수에는 useCallback 사용
const handleSearch = useCallback((query: string) => {
  setSearchQuery(query)
}, [])

// PASS: 순수 컴포넌트에는 React.memo 사용
export const MarketCard = React.memo<MarketCardProps>(({ market }) => {
  return (
    <div className="market-card">
      <h3>{market.name}</h3>
      <p>{market.description}</p>
    </div>
  )
})

코드 분할 및 지연 로딩

import { lazy, Suspense } from 'react'

// PASS: 무거운 컴포넌트는 지연 로딩
const HeavyChart = lazy(() => import('./HeavyChart'))
const ThreeJsBackground = lazy(() => import('./ThreeJsBackground'))

export function Dashboard() {
  return (
    <div>
      <Suspense fallback={<ChartSkeleton />}>
        <HeavyChart data={data} />
      </Suspense>

      <Suspense fallback={null}>
        <ThreeJsBackground />
      </Suspense>
    </div>
  )
}

긴 목록 가상화

import { useVirtualizer } from '@tanstack/react-virtual'

export function VirtualMarketList({ markets }: { markets: Market[] }) {
  const parentRef = useRef<HTMLDivElement>(null)

  const virtualizer = useVirtualizer({
    count: markets.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 100,  // 예상 행 높이
    overscan: 5  // 추가로 렌더링할 항목 수
  })

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          position: 'relative'
        }}
      >
        {virtualizer.getVirtualItems().map(virtualRow => (
          <div
            key={virtualRow.index}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: `${virtualRow.size}px`,
              transform: `translateY(${virtualRow.start}px)`
            }}
          >
            <MarketCard market={markets[virtualRow.index]} />
          </div>
        ))}
      </div>
    </div>
  )
}

폼 처리 패턴

유효성 검사가 포함된 제어 폼

interface FormData {
  name: string
  description: string
  endDate: string
}

interface FormErrors {
  name?: string
  description?: string
  endDate?: string
}

export function CreateMarketForm() {
  const [formData, setFormData] = useState<FormData>({
    name: '',
    description: '',
    endDate: ''
  })

  const [errors, setErrors] = useState<FormErrors>({})

  const validate = (): boolean => {
    const newErrors: FormErrors = {}

    if (!formData.name.trim()) {
      newErrors.name = '이름은 필수입니다'
    } else if (formData.name.length > 200) {
      newErrors.name = '이름은 200자 미만이어야 합니다'
    }

    if (!formData.description.trim()) {
      newErrors.description = '설명은 필수입니다'
    }

    if (!formData.endDate) {
      newErrors.endDate = '종료 날짜는 필수입니다'
    }

    setErrors(newErrors)
    return Object.keys(newErrors).length === 0
  }

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()

    if (!validate()) return

    try {
      await createMarket(formData)
      // 성공 처리
    } catch (error) {
      // 오류 처리
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={formData.name}
        onChange={e => setFormData(prev => ({ ...prev, name: e.target.value }))}
        placeholder="마켓 이름"
      />
      {errors.name && <span className="error">{errors.name}</span>}

      {/* 기타 필드 */}

      <button type="submit">마켓 생성</button>
    </form>
  )
}

에러 경계 패턴

interface ErrorBoundaryState {
  hasError: boolean
  error: Error | null
}

export class ErrorBoundary extends React.Component<
  { children: React.ReactNode },
  ErrorBoundaryState
> {
  state: ErrorBoundaryState = {
    hasError: false,
    error: null
  }

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error }
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    console.error('에러 경계에서 잡음:', error, errorInfo)
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="error-fallback">
          <h2>문제가 발생했습니다</h2>
          <p>{this.state.error?.message}</p>
          <button onClick={() => this.setState({ hasError: false })}>
            다시 시도
          </button>
        </div>
      )
    }

    return this.props.children
  }
}

// 사용법
<ErrorBoundary>
  <App />
</ErrorBoundary>

애니메이션 패턴

Framer Motion 애니메이션

import { motion, AnimatePresence } from 'framer-motion'

// PASS: 목록 애니메이션
export function AnimatedMarketList({ markets }: { markets: Market[] }) {
  return (
    <AnimatePresence>
      {markets.map(market => (
        <motion.div
          key={market.id}
          initial={{ opacity: 0, y: 20 }}
          animate={{ opacity: 1, y: 0 }}
          exit={{ opacity: 0, y: -20 }}
          transition={{ duration: 0.3 }}
        >
          <MarketCard market={market} />
        </motion.div>
      ))}
    </AnimatePresence>
  )
}

// PASS: 모달 애니메이션
export function Modal({ isOpen, onClose, children }: ModalProps) {
  return (
    <AnimatePresence>
      {isOpen && (
        <>
          <motion.div
            className="modal-overlay"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            onClick={onClose}
          />
          <motion.div
            className="modal-content"
            initial={{ opacity: 0, scale: 0.9, y: 20 }}
            animate={{ opacity: 1, scale: 1, y: 0 }}
            exit={{ opacity: 0, scale: 0.9, y: 20 }}
          >
            {children}
          </motion.div>
        </>
      )}
    </AnimatePresence>
  )
}

접근성 패턴

키보드 내비게이션

export function Dropdown({ options, onSelect }: DropdownProps) {
  const [isOpen, setIsOpen] = useState(false)
  const [activeIndex, setActiveIndex] = useState(0)

  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault()
        setActiveIndex(i => Math.min(i + 1, options.length - 1))
        break
      case 'ArrowUp':
        e.preventDefault()
        setActiveIndex(i => Math.max(i - 1, 0))
        break
      case 'Enter':
        e.preventDefault()
        onSelect(options[activeIndex])

[원본 문서가 잘렸습니다; 번역은 최대한 많은 내용을 포함했습니다.]

관련 스킬