인터페이스는 현지화되어 있으며, Skill 소개와 설명 문서는 선택한 언어로 AI가 자동 번역합니다. 코드·명령어·경로·전문 용어는 원문을 유지하며 원작자 문서가 우선합니다.
Skills 둘러보기
개발 및 엔지니어링 · SKILL.md

react-architecture

react-architecture

Use this skill when the user says 'React structure', 'React architecture', 'React folder', 'React component pattern', 'React hooks architecture', 'React clean arch', 'feature-based React', 'React project layout', or when structuring a React application. This skill enforces: feature-based folder structure, smart/dumb component separation, custom hooks for al…

Independent GitHub snapshot · Automated classification · Original documentation governs

Skill 문서

GitHub

name: react-architecture description: > Use this skill when the user says 'React structure', 'React architecture', 'React folder', 'React component pattern', 'React hooks architecture', 'React clean arch', 'feature-based React', 'React project layout', or when structuring a React application. This skill enforces: feature-based folder structure, smart/dumb component separation, custom hooks for all stateful logic, barrel exports only at feature boundaries, and error boundaries at route level. Requires React (package.json with react). Do NOT use for: Next.js specific patterns, Vue/Angular, or React Native. version: "1.0.0" author: "j4flmao" license: "MIT" compatibility: claude-code: true cursor: true codex: true windsurf: true tags: [frontend, react, phase-3]

React Architecture

Purpose

Organize React applications by feature, not by type. Each feature is a self-contained module with its own components, hooks, API calls, and types. No circular imports between features.

Agent Protocol

Trigger

Exact user phrases: "React structure", "React architecture", "React folder", "React component pattern", "React hooks architecture", "React clean arch", "feature-based React", "React project layout".

Input Context

Before activating, verify:

  • package.json has react dependency.
  • Whether the project uses Vite, CRA, or custom setup.

Output Artifact

No file output. Produces folder structure and code examples as text.

Response Format

Folder structure:

src/
  app/
  features/{feature}/
    api/, components/, hooks/, stores/, types/
  shared/components/

Code: show component and hook definitions. No import statements.

No preamble. No postamble. No explanations. No filler/hedging/transitions. Compress output — why use many token when few do trick.

Completion Criteria

  • Folder structure is feature-based (src/features/{feature}).
  • Smart components fetch data and manage state. Dumb components receive props.
  • Custom hooks encapsulate all stateful logic.
  • Barrel exports (index.ts) exist only at feature boundaries, not in every folder.
  • No data fetching in dumb/presentational components.
  • Error boundaries wrap each feature route.
  • Components are under 150 lines.

Max Response Length

Folder structure: unlimited. Code: 15 lines per example.

Component Architecture / Decision Trees

Architecture Options

ApproachTrade-offWhen to Use
Feature-based foldersCohesion, scalabilityAll projects with multiple features
Type-based folders (components/, hooks/)Simple for small appsSmall projects, prototypes
Smart/Dumb separationTestable, reusable UIComponents with data dependencies
Compound componentsFlexible, composableComplex UI (Dropdown, Tabs, Select)
Render propsMax flexibilityCross-cutting behavior (Mouse position, Scroll)
Custom hooksReusable stateful logicData fetching, form logic, subscriptions

Decision Tree: Component Type

Does the component fetch data or manage state?
  ├── Yes -> Smart component
  │    ├── Uses custom hooks for logic
  │    ├── Renders dumb components
  │    └── Exports from feature barrel
  └── No -> Dumb (presentational) component
       ├── Receives everything via props
       ├── No data fetching, no stores
       └── Resides in features/{feature}/components/

Decision Tree: State Management

How complex is the state?
  ├── Local (component only) -> useState, useReducer
  ├── Shared across few components -> Props lifting, Context
  └── Shared across many components ->
       ├── Server state -> TanStack Query
       ├── Client state -> Zustand, Jotai, Context
       └── URL state -> useSearchParams, react-router

Decision Tree: Custom Hook vs Component

Does the logic produce UI?
  ├── Yes -> Component (possibly compound)
  └── No -> Custom hook
       ├── Data fetching -> Query hook
       ├── Form logic -> Form hook
       ├── Browser API -> useMediaQuery, useOnlineStatus
       └── Animation -> Animation hook

Decision Tree: Data Flow Direction

Where does data come from?
  ├── Server API -> TanStack Query (useQuery/useMutation)
  ├── Parent component -> Props (drilled or composed)
  ├── Global client state -> Zustand/Jotai store
  └── URL params -> useParams, useSearchParams

Decision Tree: Composing Components

Do child components need to share state with siblings?
  ├── Yes -> Lift state to common parent
  │    ├── Only 1-2 levels -> Prop drilling
  │    └── 3+ levels -> Context or store
  └── No -> Local state in each component

Component Design Patterns

Compound Component

interface TabsContext {
  activeTab: string
  setActiveTab: (tab: string) => void
}
const TabsCtx = createContext<TabsContext | null>(null)

function Tabs({ children, defaultTab }: { children: ReactNode; defaultTab: string }) {
  const [activeTab, setActiveTab] = useState(defaultTab)
  return <TabsCtx.Provider value={{ activeTab, setActiveTab }}>{children}</TabsCtx.Provider>
}

Tabs.List = function List({ children }: { children: ReactNode }) {
  return <div role="tablist">{children}</div>
}

Tabs.Tab = function Tab({ value, children }: { value: string; children: ReactNode }) {
  const ctx = useContext(TabsCtx)!
  return <button role="tab" onClick={() => ctx.setActiveTab(value)}>{children}</button>
}

Tabs.Panel = function Panel({ value, children }: { value: string; children: ReactNode }) {
  const ctx = useContext(TabsCtx)!
  return ctx.activeTab === value ? <div role="tabpanel">{children}</div> : null
}

Custom Hook for Data Fetching

function useUsers(search: string) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['users', search],
    queryFn: () => api.getUsers({ q: search }),
    staleTime: 30_000,
    select: (users) => search
      ? users.filter(u => u.name.toLowerCase().includes(search.toLowerCase()))
      : users,
  })
  return { users: data ?? [], isLoading, error }
}

Custom Hook for Browser API

function useMediaQuery(query: string) {
  const [matches, setMatches] = useState(() => matchMedia(query).matches)
  useEffect(() => {
    const mql = matchMedia(query)
    const handler = (e: MediaQueryListEvent) => setMatches(e.matches)
    mql.addEventListener('change', handler)
    return () => mql.removeEventListener('change', handler)
  }, [query])
  return matches
}

Error Boundary Wrapper

class ErrorBoundary extends React.Component<
  { fallback?: ReactNode; children: ReactNode },
  { hasError: boolean; error: Error | null }
> {
  state = { hasError: false, error: null }
  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error }
  }
  componentDidCatch(error: Error, info: React.ErrorInfo) {
    logError(error, info.componentStack)
  }
  render() {
    if (this.state.hasError) {
      return this.props.fallback ?? <h1>Something went wrong</h1>
    }
    return this.props.children
  }
}

Route-Level Code Splitting

const UsersPage = lazy(() => import('./features/users'))
const OrdersPage = lazy(() => import('./features/orders'))

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/users" element={<UsersPage />} />
        <Route path="/orders" element={<OrdersPage />} />
      </Routes>
    </Suspense>
  )
}

State Management Patterns

Local UI State

function Toggle() {
  const [isOpen, setIsOpen] = useState(false)
  return <button onClick={() => setIsOpen(!isOpen)}>{isOpen ? 'Close' : 'Open'}</button>
}

Complex Local State with useReducer

type FormState = { name: string; email: string; errors: Partial<Record<string, string>> }
type Action = { type: 'SET_FIELD'; field: string; value: string } | { type: 'SET_ERRORS'; errors: Record<string, string> }

function formReducer(state: FormState, action: Action): FormState {
  switch (action.type) {
    case 'SET_FIELD':
      return { ...state, [action.field]: action.value }
    case 'SET_ERRORS':
      return { ...state, errors: action.errors }
    default:
      return state
  }
}

function SignupForm() {
  const [state, dispatch] = useReducer(formReducer, { name: '', email: '', errors: {} })
  return <form>{/* fields */}</form>
}

Server State with TanStack Query

function useProducts(filters: ProductFilters) {
  const queryClient = useQueryClient()
  const query = useQuery({
    queryKey: ['products', filters],
    queryFn: () => api.getProducts(filters),
    staleTime: 60_000,
  })
  const mutation = useMutation({
    mutationFn: api.createProduct,
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['products'] }),
  })
  return { products: query.data ?? [], isLoading: query.isLoading, createProduct: mutation.mutate }
}

Global Client State with Zustand

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface AuthStore {
  user: User | null
  token: string | null
  login: (email: string, password: string) => Promise<void>
  logout: () => void
}

const useAuthStore = create<AuthStore>()(
  persist(
    (set) => ({
      user: null,
      token: null,
      login: async (email, password) => {
        const { user, token } = await api.login(email, password)
        set({ user, token })
      },
      logout: () => set({ user: null, token: null }),
    }),
    { name: 'auth-storage' }
  )
)

Atomic State with Jotai

import { atom, useAtom } from 'jotai'

const searchAtom = atom('')
const debouncedSearchAtom = atom((get) => {
  const search = get(searchAtom)
  return search // debounce logic
})
const resultsAtom = atom(async (get) => {
  const q = get(debouncedSearchAtom)
  if (!q) return []
  return api.search(q)
})

function Search() {
  const [search, setSearch] = useAtom(searchAtom)
  const [results] = useAtom(resultsAtom)
  return <input value={search} onChange={(e) => setSearch(e.target.value)} />
}

Performance Optimization

Re-render Optimization

React re-renders components when state changes. Strategies to minimize:

  • React.memo for pure components receiving the same props
  • useMemo for expensive computations
  • useCallback for stable function references
  • React Compiler (automated memoization in React 19+)

Bundle Size

  • React + react-dom: ~45KB gzipped
  • Feature-based structure aids tree-shaking
  • Code-split at route level with React.lazy + Suspense
  • Lazy load heavy libraries (chart, markdown, date picker)

Virtual DOM Cost

Large lists (1000+ items) benefit from:

  • Windowed rendering (react-window, react-virtuoso)
  • Stable keys (item.id, never index)
  • Avoiding inline functions in list item renders

State Management Size

LibrarySize (gzipped)
useState/useReducer0KB (built-in)
Context0KB (built-in)
Zustand~1KB
Jotai~3KB
TanStack Query~13KB
Redux Toolkit~12KB

React Compiler (React 19+)

// vite.config.ts
import reactCompiler from 'vite-plugin-react-compiler'
export default defineConfig({
  plugins: [reactCompiler({ target: '19' }), react()],
})

The React Compiler automatically memoizes components and hooks, eliminating manual useMemo, useCallback, and React.memo.

Build & Bundle Considerations

Build Configuration

// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ['react', 'react-dom'],
          ui: ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu'],
        },
      },
    },
    target: 'es2020',
    sourcemap: false,
  },
})

Code Splitting Strategy

  • Route-level: React.lazy(() => import('./features/users'))
  • Component-level: React.lazy(() => import('./shared/components/HeavyChart'))
  • Library-level: Dynamic import inside useEffect or event handler

Bundle Analysis

npx vite build --analyze  # if using vite-plugin-visualizer
npx source-map-explorer dist/assets/*.js

Tree Shaking

  • Use named exports for libraries that support tree-shaking (lodash-es, date-fns)
  • Avoid import * as — prefer named imports
  • Configure sideEffects: false in package.json

Environment Variables

const apiUrl = import.meta.env.VITE_API_URL
const mode = import.meta.env.MODE // 'development' | 'production'

Prefix with VITE_ for Vite projects. Access via import.meta.env.

Testing Strategies

Unit Testing Components

// __tests__/Button.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { Button } from './Button'

describe('Button', () => {
  it('renders children', () => {
    render(<Button>Click</Button>)
    expect(screen.getByText('Click')).toBeDefined()
  })

  it('calls onClick when clicked', async () => {
    const onClick = vi.fn()
    render(<Button onClick={onClick}>Click</Button>)
    await userEvent.click(screen.getByText('Click'))
    expect(onClick).toHaveBeenCalledOnce()
  })
})

Testing Custom Hooks

// __tests__/useMediaQuery.test.ts
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { useMediaQuery } from './useMediaQuery'

describe('useMediaQuery', () => {
  it('returns current match state', () => {
    const { result } = renderHook(() => useMediaQuery('(min-width: 768px)'))
    expect(result.current).toBeTypeOf('boolean')
  })
})

Testing Smart Components with Providers

// __tests__/UsersPage.test.tsx
import { render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { UsersPage } from './UsersPage'

function renderWithProviders(ui: ReactElement) {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
  return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>)
}

test('shows loading state', () => {
  renderWithProviders(<UsersPage />)
  expect(screen.getByTestId('skeleton')).toBeDefined()
})

E2E Testing

// e2e/users.spec.ts
import { test, expect } from '@playwright/test'

test('creates a new user', async ({ page }) => {
  await page.goto('/users')
  await page.click('[data-testid="add-user"]')
  await page.fill('[name="name"]', 'John')
  await page.click('button[type="submit"]')
  await expect(page.locator('text=John')).toBeVisible()
})

Migration Patterns

Class Component to Functional Component

// Before
class Counter extends React.Component<{}, { count: number }> {
  state = { count: 0 }
  render() {
    return <button onClick={() => this.setState({ count: this.state.count + 1 })}>{this.state.count}</button>
  }
}

// After
function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}

Context to Zustand Migration

// Before: Context + useReducer
const AppContext = createContext(...)
const AppProvider = ({ children }) => {
  const [state, dispatch] = useReducer(appReducer, initialState)
  return <AppContext.Provider value={{ state, dispatch }}>{children}</AppContext.Provider>
}

// After: Zustand
const useAppStore = create((set) => ({
  ...initialState,
  updateUser: (user) => set({ user }),
}))

CRA to Vite Migration

npm uninstall react-scripts
npm install -D vite @vitejs/plugin-react
Move index.html to root
Rename .js to .jsx where JSX is used
Update tsconfig for Vite paths

Anti-Patterns

Props Drilling Beyond 3 Levels

// Anti-pattern
<Page user={user} />
  <Header user={user} />
    <Nav user={user} />
      <Avatar user={user} />

// Correct: composition or context
<Page>
  <Header><Nav><Avatar user={user} /></Nav></Header>

Inline Functions in List Renders

// Anti-pattern: new function on every render
{items.map(item => <Item key={item.id} onClick={() => handleClick(item.id)} />)}

// Correct: stable callback
function handleClick(id: string) { ... }
{items.map(item => <Item key={item.id} onClick={handleClick} id={item.id} />)}

Fetching on Every Render

// Anti-pattern: useQuery without staleTime — refetches on mount
useQuery({ queryKey: ['users'], queryFn: fetchUsers })

// Correct: cache for 30 seconds
useQuery({ queryKey: ['users'], queryFn: fetchUsers, staleTime: 30_000 })

Giant Component Files

// Anti-pattern: 400-line component
function ProfilePage() { /* 400 lines of JSX, logic, and styles */ }

// Correct: split into smaller components + hooks
function ProfilePage() {
  const { user } = useProfile()
  return <ProfileHeader user={user} /><ProfileContent user={user} /><ProfileFooter user={user} />
}

Direct Store Access in Dumb Components

// Anti-pattern: dumb component accesses store
function UserCard({ id }: { id: string }) {
  const user = useStore((s) => s.users[id]) // smart behavior in dumb component
  return <div>{user.name}</div>
}

Common Pitfalls

Pitfall 1: Deep Imports Across Features

// Wrong — importing directly from another feature's internals
import { UserCard } from '../users/components/UserCard'

// Correct — import from feature barrel
import { UserCard } from '../users'

Features should only expose their public API through the barrel (index.ts). Deep imports create tight coupling.

Pitfall 2: Barrel Files in Every Folder

// Wrong — barrel in api/, components/, hooks/, stores/, types/
// Correct — ONE barrel at feature level (features/users/index.ts)

Barrel files at every subfolder level create circular import risks and slower builds. Only barrel at the feature boundary.

Pitfall 3: Smart Components Over 150 Lines

Components over 150 lines are doing too much. Extract:

  • Data fetching -> custom hook
  • Business logic -> custom hook or utility
  • JSX subsections -> dumb child components

Pitfall 4: Data Fetching in Dumb Components

Dumb components receive props only. If a component calls useQuery or accesses a store, it is a smart component and should be in the feature's component folder.

Pitfall 5: Prop Drilling Beyond 3 Levels

// Wrong — prop drilling through 4 components
<Page user={user} />
  <Sidebar user={user} />
    <Nav user={user} />
      <Avatar user={user} />

// Correct — Context or composition
<Page>
  <Sidebar>
    <Nav user={user} /> {/* Only drill where needed */}

At 3+ levels of prop drilling, consider Context, composition, or state management.

Compared With

Feature-Based vs Type-Based Structure

AspectFeature-BasedType-Based
CohesionHigh (feature files colocated)Low (feature files spread)
ScalingExcellentPoor after 20+ files
NavigationBy feature in IDEBy type in IDE
RefactoringIsolated to featureCross-cutting changes
ReusabilityShared via shared/ folderNatural cross-feature sharing

React Hooks vs Vue Composables

Both encapsulate reactive state. React hooks run on every render and depend on call order; Vue composables run once and use proxy-based reactivity. React hooks are more flexible but have stricter rules.

React Compound Components vs Slots

Compound components (<Select><Select.Trigger/><Select.Options/></Select>) give explicit control over rendered structure. Slots (Svelte, Vue) are simpler but less flexible for advanced composition.

Ecosystem & Tooling

State Management

LibraryBest For
TanStack QueryServer state (API data)
ZustandSimple global client state
JotaiAtomic state with React-like API
Redux ToolkitLarge apps with complex state logic
ContextLow-frequency global state (theme, auth)
useReducerLocal complex state (forms, wizards)

UI Libraries

LibraryStyle
Radix UIHeadless primitives
shadcn/uiCopy-paste components
MUIFull Material Design
Chakra UIAccessible by default
Tailwind CSSUtility-first CSS

Testing Tools

ToolPurpose
VitestUnit and integration tests
React Testing LibraryComponent testing
PlaywrightE2E testing
StorybookVisual component development

Build Tools

ToolPurpose
ViteFast dev server and build
Next.jsFull-stack React framework
RemixSSR-focused framework
React RouterClient-side routing

Community

  • Docs: react.dev
  • GitHub: github.com/facebook/react
  • Discord: reactiflux.com
  • Trends: reacttrends.io

Workflow

Step 1: Feature-Based Structure

src/
  app/
    App.tsx                         -- App shell, routing, global providers
    router.tsx                      -- Route definitions
    providers.tsx                   -- Theme, QueryClient, Auth providers
  features/
    users/
      api/
        useUsersQuery.ts            -- Server state hooks
        useCreateUserMutation.ts
      components/
        UserList.tsx                -- Smart (connects to data)
        UserCard.tsx                -- Dumb (receives props)
      hooks/
        useUserFilter.ts            -- Client state logic
      stores/
        userStore.ts                -- Zustand store slice
      types/
        index.ts
      index.ts                      -- Public API
    orders/
      api/
      components/
      hooks/
      types/
      index.ts
  shared/
    components/
      Button/
      Modal/
      DataTable/
    hooks/
      useDebounce.ts
      useMediaQuery.ts
    utils/
      formatDate.ts
    types/
      common.ts
  lib/
    api.ts                          -- HTTP client wrapper
    query.ts                        -- TanStack Query config
  assets/

Step 2: Smart vs Dumb Components

// Dumb (presentational) — no data fetching, no business logic
interface ButtonProps {
  variant: 'primary' | 'secondary'
  children: React.ReactNode
  onClick: () => void
  disabled?: boolean
}

function Button({ variant, children, onClick, disabled }: ButtonProps) {
  return (
    <button className={buttonVariants({ variant })} onClick={onClick} disabled={disabled}>
      {children}
    </button>
  )
}

// Smart (container) — orchestrates data and logic
function UserListFeature() {
  const { data: users, isLoading } = useUsersQuery()
  const deleteUser = useDeleteUserMutation()

  if (isLoading) return <Skeleton />
  return (
    <div>
      {users?.map(user => (
        <UserCard key={user.id} user={user} onDelete={() => deleteUser.mutate(user.id)} />
      ))}
    </div>
  )
}

Step 3: Custom Hook Patterns

function useUserFilter(users: User[], search: string) {
  return useMemo(() => {
    if (!search) return users
    return users.filter(u =>
      u.name.toLowerCase().includes(search.toLowerCase())
    )
  }, [users, search])
}

function useUsersQuery() {
  return useQuery({
    queryKey: ['users'],
    queryFn: () => api.getUsers(),
    staleTime: 30_000,
  })
}

Step 4: Feature Public API

// features/users/index.ts
export { UserListFeature } from './components/UserListFeature'
export { useUsersQuery } from './api/useUsersQuery'
export type { User, CreateUserDTO } from './types'

Features import from other features ONLY through their public API (index.ts).

Step 5: Error Boundaries

class ErrorBoundary extends React.Component<
  { fallback: React.ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false }

  static getDerivedStateFromError() { return { hasError: true } }

  componentDidCatch(error: Error, info: React.ErrorInfo) {
    console.error('Error caught:', error, info)
  }

  render() {
    if (this.state.hasError) return this.props.fallback
    return this.props.children
  }
}

Step 6: Code Splitting

const UsersPage = lazy(() => import('./features/users/pages/UsersPage'))
const OrdersPage = lazy(() => import('./features/orders/pages/OrdersPage'))

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/users" element={<UsersPage />} />
        <Route path="/orders" element={<OrdersPage />} />
      </Routes>
    </Suspense>
  )
}

Step 7: React Compiler (React 19+)

// Install: npm install -D vite-plugin-react-compiler
import reactCompiler from 'vite-plugin-react-compiler'

export default defineConfig({
  plugins: [
    reactCompiler({ target: '19' }),
    react(),
  ],
})

The React Compiler automatically memoizes components and hooks, eliminating manual useMemo, useCallback, and React.memo.

Rules

  • Smart components connect to data/stores. Dumb components receive everything via props.
  • Custom hooks start with use and return objects (not arrays) when returning more than 2 values.
  • Barrel files (index.ts) are at feature boundaries only, not in every subfolder.
  • Components stay under 150 lines. Split them.
  • Dumb components never fetch data, never access stores, never call mutations.
  • Features import from other features only via their public API (index.ts). No deep imports.
  • Use TanStack Query for server state; Zustand or Context for client state.
  • Code-split at route level with React.lazy + Suspense.
  • Use React Compiler in React 19 projects to automate memoization.
  • Test smart components via hooks; test dumb components via props.

References

  • references/component-patterns.md — React Component Patterns
  • references/folder-structure.md — React Folder Structure
  • references/hooks-guide.md — React Hooks Guide
  • references/react-19-patterns.md — React 19 Patterns
  • references/react-compiler.md — React Compiler
  • references/rendering-patterns.md — React Rendering Patterns
  • references/react-server-components.md — React Server Components
  • references/react-compiler-optimizations.md — React Compiler Optimizations

Handoff

No artifact produced. Next skill: react-nextjs (if using Next.js) or frontend-testing. Carry forward: feature organization, component patterns, custom hook conventions.

저장한 Skills