AgentSkillsCN

react-patterns

React 特色开发模式——专为 UGCUNTIMATE 打造:API 缓存、代码分割、内存管理、WebSocket 通信、上下文模式

SKILL.md
--- frontmatter
name: react-patterns
description: "React patterns เฉพาะ UGCUNTIMATE - API caching, code splitting, memory management, WebSocket, Context patterns"

React Patterns for UGCUNTIMATE

Project-specific React patterns ที่พัฒนาจาก decisions และ bug fixes

When to Apply

Reference these patterns when:

  • จัดการ API calls และ caching
  • ทำ code splitting
  • จัดการ state ที่อาจ grow unbounded
  • ทำงานกับ WebSocket (Reverb)
  • ใช้ Context สำหรับ global state

1. API Cache Pattern

In-memory cache with TTL-based expiration (ตาม decision ที่เลือก)

typescript
// lib/apiCache.ts
interface CacheEntry<T> {
  data: T
  timestamp: number
  ttl: number
}

class ApiCache {
  private cache = new Map<string, CacheEntry<unknown>>()
  private defaultTTL = 5 * 60 * 1000 // 5 minutes

  get<T>(key: string): T | null {
    const entry = this.cache.get(key) as CacheEntry<T> | undefined

    if (!entry) return null

    if (Date.now() - entry.timestamp > entry.ttl) {
      this.cache.delete(key)
      return null
    }

    return entry.data
  }

  set<T>(key: string, data: T, ttl = this.defaultTTL): void {
    this.cache.set(key, {
      data,
      timestamp: Date.now(),
      ttl,
    })
  }

  invalidate(pattern: string): void {
    // Pattern-based invalidation
    for (const key of this.cache.keys()) {
      if (key.includes(pattern)) {
        this.cache.delete(key)
      }
    }
  }

  clear(): void {
    this.cache.clear()
  }
}

export const apiCache = new ApiCache()

Usage with API calls

typescript
// hooks/useProjects.ts
export function useProjects() {
  const [projects, setProjects] = useState<Project[]>([])
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    const fetchProjects = async () => {
      // Check cache first
      const cached = apiCache.get<Project[]>('projects')
      if (cached) {
        setProjects(cached)
        setLoading(false)
        return
      }

      // Fetch from API
      const data = await api.get('/projects')
      apiCache.set('projects', data, 5 * 60 * 1000) // 5 min TTL
      setProjects(data)
      setLoading(false)
    }

    fetchProjects()
  }, [])

  const invalidateCache = () => apiCache.invalidate('projects')

  return { projects, loading, invalidateCache }
}

What NOT to cache

typescript
// ❌ Don't cache real-time data
// - Project status (changes during generation)
// - Asset progress
// - WebSocket events

// ✅ Safe to cache
// - User profile
// - Project list (invalidate on create/delete)
// - Settings
// - API keys list

2. Code Splitting Pattern

React.lazy + Suspense (ตาม decision ที่เลือก)

typescript
// App.tsx - Route-based splitting
import { lazy, Suspense } from 'react'
import { Routes, Route } from 'react-router-dom'

// ✅ Lazy load heavy pages
const Dashboard = lazy(() => import('./pages/Dashboard'))
const ProjectDetail = lazy(() => import('./pages/ProjectDetail'))
const Settings = lazy(() => import('./pages/Settings'))

// Loading component
function PageLoader() {
  return (
    <div className="flex items-center justify-center h-screen">
      <Spinner size="lg" />
    </div>
  )
}

export function App() {
  return (
    <Suspense fallback={<PageLoader />}>
      <Routes>
        <Route path="/" element={<Dashboard />} />
        <Route path="/projects/:id" element={<ProjectDetail />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  )
}

Component-level splitting

typescript
// ✅ Heavy components that aren't always visible
const VideoPlayer = lazy(() => import('./components/VideoPlayer'))
const ChartDashboard = lazy(() => import('./components/ChartDashboard'))

function ProjectDetail() {
  const [showVideo, setShowVideo] = useState(false)

  return (
    <div>
      {showVideo && (
        <Suspense fallback={<VideoSkeleton />}>
          <VideoPlayer />
        </Suspense>
      )}
    </div>
  )
}

Preloading

typescript
// Preload on hover for better UX
const ProjectDetail = lazy(() => import('./pages/ProjectDetail'))

function ProjectCard({ project }: { project: Project }) {
  const preload = () => {
    // Trigger dynamic import on hover
    import('./pages/ProjectDetail')
  }

  return (
    <Link
      to={`/projects/${project.id}`}
      onMouseEnter={preload}
    >
      {project.name}
    </Link>
  )
}

3. Memory Management Pattern

ป้องกัน memory leak จาก unbounded arrays (จาก bug fix)

typescript
// ✅ GOOD: Limit array sizes
const MAX_EVENTS = 100
const MAX_LOGS = 500
const MAX_COMPLETED_STEPS = 50

function usePipelineSocket(projectId: string) {
  const [events, setEvents] = useState<PipelineEvent[]>([])
  const [logs, setLogs] = useState<LogEntry[]>([])
  const [completedSteps, setCompletedSteps] = useState<string[]>([])

  useEffect(() => {
    const channel = echo.channel(`project.${projectId}`)

    channel.listen('PipelineEvent', (event: PipelineEvent) => {
      setEvents(prev => {
        const updated = [...prev, event]
        // ✅ Keep only last N items
        return updated.slice(-MAX_EVENTS)
      })
    })

    channel.listen('LogEntry', (log: LogEntry) => {
      setLogs(prev => {
        const updated = [...prev, log]
        return updated.slice(-MAX_LOGS)
      })
    })

    channel.listen('StepCompleted', (step: string) => {
      setCompletedSteps(prev => {
        const updated = [...prev, step]
        return updated.slice(-MAX_COMPLETED_STEPS)
      })
    })

    return () => channel.stopListening()
  }, [projectId])

  return { events, logs, completedSteps }
}

General rule

typescript
// ❌ BAD: Unbounded growth
setItems(prev => [...prev, newItem])

// ✅ GOOD: With limit
const MAX_ITEMS = 100
setItems(prev => [...prev, newItem].slice(-MAX_ITEMS))

// ✅ GOOD: Or use a circular buffer approach
setItems(prev => {
  if (prev.length >= MAX_ITEMS) {
    return [...prev.slice(1), newItem]
  }
  return [...prev, newItem]
})

4. WebSocket Pattern (Reverb)

typescript
// hooks/useProjectSocket.ts
import Echo from 'laravel-echo'
import Pusher from 'pusher-js'

// Initialize Echo (usually in main.tsx)
declare global {
  interface Window {
    Echo: Echo
    Pusher: typeof Pusher
  }
}

window.Pusher = Pusher
window.Echo = new Echo({
  broadcaster: 'reverb',
  key: import.meta.env.VITE_REVERB_APP_KEY,
  wsHost: import.meta.env.VITE_REVERB_HOST,
  wsPort: import.meta.env.VITE_REVERB_PORT,
  forceTLS: false,
  disableStats: true,
})

// Hook for project updates
export function useProjectSocket(projectId: string) {
  const [status, setStatus] = useState<ProjectStatus>('pending')
  const [progress, setProgress] = useState(0)

  useEffect(() => {
    const channel = window.Echo.private(`project.${projectId}`)

    channel
      .listen('ProjectStatusChanged', (e: { status: ProjectStatus }) => {
        setStatus(e.status)
      })
      .listen('ProjectProgress', (e: { progress: number }) => {
        setProgress(e.progress)
      })

    // Cleanup
    return () => {
      channel.stopListening('ProjectStatusChanged')
      channel.stopListening('ProjectProgress')
      window.Echo.leave(`project.${projectId}`)
    }
  }, [projectId])

  return { status, progress }
}

Error handling for WebSocket

typescript
export function useProjectSocket(projectId: string) {
  const [connected, setConnected] = useState(false)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    const channel = window.Echo.private(`project.${projectId}`)

    channel
      .subscribed(() => {
        setConnected(true)
        setError(null)
      })
      .error((err: Error) => {
        setConnected(false)
        setError(err.message)
        console.error('WebSocket error:', err)
      })

    // ... listeners

    return () => window.Echo.leave(`project.${projectId}`)
  }, [projectId])

  return { connected, error }
}

5. Context Patterns

Auth Context

typescript
// contexts/AuthContext.tsx
interface AuthContextType {
  user: User | null
  token: string | null
  login: (email: string, password: string) => Promise<void>
  logout: () => void
  isAuthenticated: boolean
}

const AuthContext = createContext<AuthContextType | undefined>(undefined)

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null)
  const [token, setToken] = useState<string | null>(
    () => localStorage.getItem('token')
  )

  const login = async (email: string, password: string) => {
    const response = await api.post('/auth/login', { email, password })
    setToken(response.token)
    setUser(response.user)
    localStorage.setItem('token', response.token)
  }

  const logout = () => {
    setToken(null)
    setUser(null)
    localStorage.removeItem('token')
    apiCache.clear() // Clear cache on logout
  }

  return (
    <AuthContext.Provider value={{
      user,
      token,
      login,
      logout,
      isAuthenticated: !!token,
    }}>
      {children}
    </AuthContext.Provider>
  )
}

export function useAuth() {
  const context = useContext(AuthContext)
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider')
  }
  return context
}

Theme Context

typescript
// contexts/ThemeContext.tsx
type Theme = 'light' | 'dark' | 'system'

interface ThemeContextType {
  theme: Theme
  setTheme: (theme: Theme) => void
  resolvedTheme: 'light' | 'dark'
}

const ThemeContext = createContext<ThemeContextType | undefined>(undefined)

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>(
    () => (localStorage.getItem('theme') as Theme) || 'system'
  )

  const resolvedTheme = useMemo(() => {
    if (theme === 'system') {
      return window.matchMedia('(prefers-color-scheme: dark)').matches
        ? 'dark'
        : 'light'
    }
    return theme
  }, [theme])

  useEffect(() => {
    document.documentElement.classList.toggle('dark', resolvedTheme === 'dark')
    localStorage.setItem('theme', theme)
  }, [theme, resolvedTheme])

  return (
    <ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
      {children}
    </ThemeContext.Provider>
  )
}

export function useTheme() {
  const context = useContext(ThemeContext)
  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider')
  }
  return context
}

6. API URL Pattern

CRITICAL: Frontend VITE_API_URL ต้องไม่มี /api ต่อท้าย

typescript
// lib/api.ts
const API_URL = import.meta.env.VITE_API_URL // e.g., http://localhost:8000

// ✅ GOOD: api.ts appends /api
export const api = {
  get: (path: string) => fetch(`${API_URL}/api${path}`),
  post: (path: string, data: unknown) => fetch(`${API_URL}/api${path}`, {
    method: 'POST',
    body: JSON.stringify(data),
  }),
}

// .env
// ✅ GOOD
VITE_API_URL=http://localhost:8000

// ❌ BAD - will result in /api/api/...
VITE_API_URL=http://localhost:8000/api

7. Error Boundary Pattern

typescript
// components/ErrorBoundary.tsx
interface Props {
  children: React.ReactNode
  fallback?: React.ReactNode
}

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

export class ErrorBoundary extends React.Component<Props, State> {
  state: State = { hasError: false, error: null }

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

  componentDidCatch(error: Error, info: React.ErrorInfo) {
    console.error('Error caught by boundary:', error, info)
    // Could send to error tracking service
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || (
        <div className="p-4 bg-red-50 text-red-600 rounded">
          <h2>Something went wrong</h2>
          <p>{this.state.error?.message}</p>
          <button
            onClick={() => this.setState({ hasError: false })}
            className="mt-2 btn btn-primary"
          >
            Try again
          </button>
        </div>
      )
    }

    return this.props.children
  }
}

// Usage - wrap critical sections
<ErrorBoundary fallback={<ProjectErrorFallback />}>
  <ProjectDetail />
</ErrorBoundary>

Quick Reference

PatternWhen to Use
API CacheStatic data ที่ไม่เปลี่ยนบ่อย
Code SplittingHeavy pages/components
Memory LimitsArrays ที่ grow จาก events
WebSocket HookReal-time updates
ContextGlobal state (auth, theme)
Error BoundaryGraceful error handling

Anti-Patterns to Avoid

Anti-PatternProblemSolution
Unbounded arraysMemory leakLimit with .slice(-MAX)
Cache real-time dataStale dataOnly cache static data
API URL with /apiDouble /api/apiRemove from env
No error boundariesApp crashesWrap critical sections
No cleanup in useEffectMemory leakReturn cleanup function