mirror of
https://github.com/wshobson/agents.git
synced 2026-03-18 09:37:15 +00:00
feat: add 5 new specialized agents with 20 skills
Add domain expert agents with comprehensive skill sets: - service-mesh-expert (cloud-infrastructure): Istio/Linkerd patterns, mTLS, observability - event-sourcing-architect (backend-development): CQRS, event stores, projections, sagas - vector-database-engineer (llm-application-dev): embeddings, similarity search, hybrid search - monorepo-architect (developer-essentials): Nx, Turborepo, Bazel, pnpm workspaces - threat-modeling-expert (security-scanning): STRIDE, attack trees, security requirements Update all documentation to reflect correct counts: - 67 plugins, 99 agents, 107 skills, 71 commands
This commit is contained in:
@@ -0,0 +1,544 @@
|
||||
---
|
||||
name: nextjs-app-router-patterns
|
||||
description: Master Next.js 14+ App Router with Server Components, streaming, parallel routes, and advanced data fetching. Use when building Next.js applications, implementing SSR/SSG, or optimizing React Server Components.
|
||||
---
|
||||
|
||||
# Next.js App Router Patterns
|
||||
|
||||
Comprehensive patterns for Next.js 14+ App Router architecture, Server Components, and modern full-stack React development.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- Building new Next.js applications with App Router
|
||||
- Migrating from Pages Router to App Router
|
||||
- Implementing Server Components and streaming
|
||||
- Setting up parallel and intercepting routes
|
||||
- Optimizing data fetching and caching
|
||||
- Building full-stack features with Server Actions
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### 1. Rendering Modes
|
||||
|
||||
| Mode | Where | When to Use |
|
||||
|------|-------|-------------|
|
||||
| **Server Components** | Server only | Data fetching, heavy computation, secrets |
|
||||
| **Client Components** | Browser | Interactivity, hooks, browser APIs |
|
||||
| **Static** | Build time | Content that rarely changes |
|
||||
| **Dynamic** | Request time | Personalized or real-time data |
|
||||
| **Streaming** | Progressive | Large pages, slow data sources |
|
||||
|
||||
### 2. File Conventions
|
||||
|
||||
```
|
||||
app/
|
||||
├── layout.tsx # Shared UI wrapper
|
||||
├── page.tsx # Route UI
|
||||
├── loading.tsx # Loading UI (Suspense)
|
||||
├── error.tsx # Error boundary
|
||||
├── not-found.tsx # 404 UI
|
||||
├── route.ts # API endpoint
|
||||
├── template.tsx # Re-mounted layout
|
||||
├── default.tsx # Parallel route fallback
|
||||
└── opengraph-image.tsx # OG image generation
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
// app/layout.tsx
|
||||
import { Inter } from 'next/font/google'
|
||||
import { Providers } from './providers'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata = {
|
||||
title: { default: 'My App', template: '%s | My App' },
|
||||
description: 'Built with Next.js App Router',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={inter.className}>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
// app/page.tsx - Server Component by default
|
||||
async function getProducts() {
|
||||
const res = await fetch('https://api.example.com/products', {
|
||||
next: { revalidate: 3600 }, // ISR: revalidate every hour
|
||||
})
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export default async function HomePage() {
|
||||
const products = await getProducts()
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Products</h1>
|
||||
<ProductGrid products={products} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Patterns
|
||||
|
||||
### Pattern 1: Server Components with Data Fetching
|
||||
|
||||
```typescript
|
||||
// app/products/page.tsx
|
||||
import { Suspense } from 'react'
|
||||
import { ProductList, ProductListSkeleton } from '@/components/products'
|
||||
import { FilterSidebar } from '@/components/filters'
|
||||
|
||||
interface SearchParams {
|
||||
category?: string
|
||||
sort?: 'price' | 'name' | 'date'
|
||||
page?: string
|
||||
}
|
||||
|
||||
export default async function ProductsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const params = await searchParams
|
||||
|
||||
return (
|
||||
<div className="flex gap-8">
|
||||
<FilterSidebar />
|
||||
<Suspense
|
||||
key={JSON.stringify(params)}
|
||||
fallback={<ProductListSkeleton />}
|
||||
>
|
||||
<ProductList
|
||||
category={params.category}
|
||||
sort={params.sort}
|
||||
page={Number(params.page) || 1}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// components/products/ProductList.tsx - Server Component
|
||||
async function getProducts(filters: ProductFilters) {
|
||||
const res = await fetch(
|
||||
`${process.env.API_URL}/products?${new URLSearchParams(filters)}`,
|
||||
{ next: { tags: ['products'] } }
|
||||
)
|
||||
if (!res.ok) throw new Error('Failed to fetch products')
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function ProductList({ category, sort, page }: ProductFilters) {
|
||||
const { products, totalPages } = await getProducts({ category, sort, page })
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{products.map((product) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
<Pagination currentPage={page} totalPages={totalPages} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Client Components with 'use client'
|
||||
|
||||
```typescript
|
||||
// components/products/AddToCartButton.tsx
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { addToCart } from '@/app/actions/cart'
|
||||
|
||||
export function AddToCartButton({ productId }: { productId: string }) {
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleClick = () => {
|
||||
setError(null)
|
||||
startTransition(async () => {
|
||||
const result = await addToCart(productId)
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={isPending}
|
||||
className="btn-primary"
|
||||
>
|
||||
{isPending ? 'Adding...' : 'Add to Cart'}
|
||||
</button>
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Server Actions
|
||||
|
||||
```typescript
|
||||
// app/actions/cart.ts
|
||||
'use server'
|
||||
|
||||
import { revalidateTag } from 'next/cache'
|
||||
import { cookies } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export async function addToCart(productId: string) {
|
||||
const cookieStore = await cookies()
|
||||
const sessionId = cookieStore.get('session')?.value
|
||||
|
||||
if (!sessionId) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
try {
|
||||
await db.cart.upsert({
|
||||
where: { sessionId_productId: { sessionId, productId } },
|
||||
update: { quantity: { increment: 1 } },
|
||||
create: { sessionId, productId, quantity: 1 },
|
||||
})
|
||||
|
||||
revalidateTag('cart')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { error: 'Failed to add item to cart' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkout(formData: FormData) {
|
||||
const address = formData.get('address') as string
|
||||
const payment = formData.get('payment') as string
|
||||
|
||||
// Validate
|
||||
if (!address || !payment) {
|
||||
return { error: 'Missing required fields' }
|
||||
}
|
||||
|
||||
// Process order
|
||||
const order = await processOrder({ address, payment })
|
||||
|
||||
// Redirect to confirmation
|
||||
redirect(`/orders/${order.id}/confirmation`)
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Parallel Routes
|
||||
|
||||
```typescript
|
||||
// app/dashboard/layout.tsx
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
analytics,
|
||||
team,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
analytics: React.ReactNode
|
||||
team: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="dashboard-grid">
|
||||
<main>{children}</main>
|
||||
<aside className="analytics-panel">{analytics}</aside>
|
||||
<aside className="team-panel">{team}</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// app/dashboard/@analytics/page.tsx
|
||||
export default async function AnalyticsSlot() {
|
||||
const stats = await getAnalytics()
|
||||
return <AnalyticsChart data={stats} />
|
||||
}
|
||||
|
||||
// app/dashboard/@analytics/loading.tsx
|
||||
export default function AnalyticsLoading() {
|
||||
return <ChartSkeleton />
|
||||
}
|
||||
|
||||
// app/dashboard/@team/page.tsx
|
||||
export default async function TeamSlot() {
|
||||
const members = await getTeamMembers()
|
||||
return <TeamList members={members} />
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 5: Intercepting Routes (Modal Pattern)
|
||||
|
||||
```typescript
|
||||
// File structure for photo modal
|
||||
// app/
|
||||
// ├── @modal/
|
||||
// │ ├── (.)photos/[id]/page.tsx # Intercept
|
||||
// │ └── default.tsx
|
||||
// ├── photos/
|
||||
// │ └── [id]/page.tsx # Full page
|
||||
// └── layout.tsx
|
||||
|
||||
// app/@modal/(.)photos/[id]/page.tsx
|
||||
import { Modal } from '@/components/Modal'
|
||||
import { PhotoDetail } from '@/components/PhotoDetail'
|
||||
|
||||
export default async function PhotoModal({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id } = await params
|
||||
const photo = await getPhoto(id)
|
||||
|
||||
return (
|
||||
<Modal>
|
||||
<PhotoDetail photo={photo} />
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// app/photos/[id]/page.tsx - Full page version
|
||||
export default async function PhotoPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id } = await params
|
||||
const photo = await getPhoto(id)
|
||||
|
||||
return (
|
||||
<div className="photo-page">
|
||||
<PhotoDetail photo={photo} />
|
||||
<RelatedPhotos photoId={id} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// app/layout.tsx
|
||||
export default function RootLayout({
|
||||
children,
|
||||
modal,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
modal: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
{children}
|
||||
{modal}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 6: Streaming with Suspense
|
||||
|
||||
```typescript
|
||||
// app/product/[id]/page.tsx
|
||||
import { Suspense } from 'react'
|
||||
|
||||
export default async function ProductPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id } = await params
|
||||
|
||||
// This data loads first (blocking)
|
||||
const product = await getProduct(id)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Immediate render */}
|
||||
<ProductHeader product={product} />
|
||||
|
||||
{/* Stream in reviews */}
|
||||
<Suspense fallback={<ReviewsSkeleton />}>
|
||||
<Reviews productId={id} />
|
||||
</Suspense>
|
||||
|
||||
{/* Stream in recommendations */}
|
||||
<Suspense fallback={<RecommendationsSkeleton />}>
|
||||
<Recommendations productId={id} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// These components fetch their own data
|
||||
async function Reviews({ productId }: { productId: string }) {
|
||||
const reviews = await getReviews(productId) // Slow API
|
||||
return <ReviewList reviews={reviews} />
|
||||
}
|
||||
|
||||
async function Recommendations({ productId }: { productId: string }) {
|
||||
const products = await getRecommendations(productId) // ML-based, slow
|
||||
return <ProductCarousel products={products} />
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 7: Route Handlers (API Routes)
|
||||
|
||||
```typescript
|
||||
// app/api/products/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const category = searchParams.get('category')
|
||||
|
||||
const products = await db.product.findMany({
|
||||
where: category ? { category } : undefined,
|
||||
take: 20,
|
||||
})
|
||||
|
||||
return NextResponse.json(products)
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json()
|
||||
|
||||
const product = await db.product.create({
|
||||
data: body,
|
||||
})
|
||||
|
||||
return NextResponse.json(product, { status: 201 })
|
||||
}
|
||||
|
||||
// app/api/products/[id]/route.ts
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const product = await db.product.findUnique({ where: { id } })
|
||||
|
||||
if (!product) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Product not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(product)
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 8: Metadata and SEO
|
||||
|
||||
```typescript
|
||||
// app/products/[slug]/page.tsx
|
||||
import { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ slug: string }>
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params
|
||||
const product = await getProduct(slug)
|
||||
|
||||
if (!product) return {}
|
||||
|
||||
return {
|
||||
title: product.name,
|
||||
description: product.description,
|
||||
openGraph: {
|
||||
title: product.name,
|
||||
description: product.description,
|
||||
images: [{ url: product.image, width: 1200, height: 630 }],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: product.name,
|
||||
description: product.description,
|
||||
images: [product.image],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const products = await db.product.findMany({ select: { slug: true } })
|
||||
return products.map((p) => ({ slug: p.slug }))
|
||||
}
|
||||
|
||||
export default async function ProductPage({ params }: Props) {
|
||||
const { slug } = await params
|
||||
const product = await getProduct(slug)
|
||||
|
||||
if (!product) notFound()
|
||||
|
||||
return <ProductDetail product={product} />
|
||||
}
|
||||
```
|
||||
|
||||
## Caching Strategies
|
||||
|
||||
### Data Cache
|
||||
|
||||
```typescript
|
||||
// No cache (always fresh)
|
||||
fetch(url, { cache: 'no-store' })
|
||||
|
||||
// Cache forever (static)
|
||||
fetch(url, { cache: 'force-cache' })
|
||||
|
||||
// ISR - revalidate after 60 seconds
|
||||
fetch(url, { next: { revalidate: 60 } })
|
||||
|
||||
// Tag-based invalidation
|
||||
fetch(url, { next: { tags: ['products'] } })
|
||||
|
||||
// Invalidate via Server Action
|
||||
'use server'
|
||||
import { revalidateTag, revalidatePath } from 'next/cache'
|
||||
|
||||
export async function updateProduct(id: string, data: ProductData) {
|
||||
await db.product.update({ where: { id }, data })
|
||||
revalidateTag('products')
|
||||
revalidatePath('/products')
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Do's
|
||||
- **Start with Server Components** - Add 'use client' only when needed
|
||||
- **Colocate data fetching** - Fetch data where it's used
|
||||
- **Use Suspense boundaries** - Enable streaming for slow data
|
||||
- **Leverage parallel routes** - Independent loading states
|
||||
- **Use Server Actions** - For mutations with progressive enhancement
|
||||
|
||||
### Don'ts
|
||||
- **Don't pass serializable data** - Server → Client boundary limitations
|
||||
- **Don't use hooks in Server Components** - No useState, useEffect
|
||||
- **Don't fetch in Client Components** - Use Server Components or React Query
|
||||
- **Don't over-nest layouts** - Each layout adds to the component tree
|
||||
- **Don't ignore loading states** - Always provide loading.tsx or Suspense
|
||||
|
||||
## Resources
|
||||
|
||||
- [Next.js App Router Documentation](https://nextjs.org/docs/app)
|
||||
- [Server Components RFC](https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md)
|
||||
- [Vercel Templates](https://vercel.com/templates/next.js)
|
||||
@@ -0,0 +1,671 @@
|
||||
---
|
||||
name: react-native-architecture
|
||||
description: Build production React Native apps with Expo, navigation, native modules, offline sync, and cross-platform patterns. Use when developing mobile apps, implementing native integrations, or architecting React Native projects.
|
||||
---
|
||||
|
||||
# React Native Architecture
|
||||
|
||||
Production-ready patterns for React Native development with Expo, including navigation, state management, native modules, and offline-first architecture.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- Starting a new React Native or Expo project
|
||||
- Implementing complex navigation patterns
|
||||
- Integrating native modules and platform APIs
|
||||
- Building offline-first mobile applications
|
||||
- Optimizing React Native performance
|
||||
- Setting up CI/CD for mobile releases
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### 1. Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Expo Router screens
|
||||
│ ├── (auth)/ # Auth group
|
||||
│ ├── (tabs)/ # Tab navigation
|
||||
│ └── _layout.tsx # Root layout
|
||||
├── components/
|
||||
│ ├── ui/ # Reusable UI components
|
||||
│ └── features/ # Feature-specific components
|
||||
├── hooks/ # Custom hooks
|
||||
├── services/ # API and native services
|
||||
├── stores/ # State management
|
||||
├── utils/ # Utilities
|
||||
└── types/ # TypeScript types
|
||||
```
|
||||
|
||||
### 2. Expo vs Bare React Native
|
||||
|
||||
| Feature | Expo | Bare RN |
|
||||
|---------|------|---------|
|
||||
| Setup complexity | Low | High |
|
||||
| Native modules | EAS Build | Manual linking |
|
||||
| OTA updates | Built-in | Manual setup |
|
||||
| Build service | EAS | Custom CI |
|
||||
| Custom native code | Config plugins | Direct access |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Create new Expo project
|
||||
npx create-expo-app@latest my-app -t expo-template-blank-typescript
|
||||
|
||||
# Install essential dependencies
|
||||
npx expo install expo-router expo-status-bar react-native-safe-area-context
|
||||
npx expo install @react-native-async-storage/async-storage
|
||||
npx expo install expo-secure-store expo-haptics
|
||||
```
|
||||
|
||||
```typescript
|
||||
// app/_layout.tsx
|
||||
import { Stack } from 'expo-router'
|
||||
import { ThemeProvider } from '@/providers/ThemeProvider'
|
||||
import { QueryProvider } from '@/providers/QueryProvider'
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<QueryProvider>
|
||||
<ThemeProvider>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="(auth)" />
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
|
||||
</Stack>
|
||||
</ThemeProvider>
|
||||
</QueryProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Patterns
|
||||
|
||||
### Pattern 1: Expo Router Navigation
|
||||
|
||||
```typescript
|
||||
// app/(tabs)/_layout.tsx
|
||||
import { Tabs } from 'expo-router'
|
||||
import { Home, Search, User, Settings } from 'lucide-react-native'
|
||||
import { useTheme } from '@/hooks/useTheme'
|
||||
|
||||
export default function TabLayout() {
|
||||
const { colors } = useTheme()
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: colors.primary,
|
||||
tabBarInactiveTintColor: colors.textMuted,
|
||||
tabBarStyle: { backgroundColor: colors.background },
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Home',
|
||||
tabBarIcon: ({ color, size }) => <Home size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="search"
|
||||
options={{
|
||||
title: 'Search',
|
||||
tabBarIcon: ({ color, size }) => <Search size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: 'Profile',
|
||||
tabBarIcon: ({ color, size }) => <User size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: 'Settings',
|
||||
tabBarIcon: ({ color, size }) => <Settings size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
// app/(tabs)/profile/[id].tsx - Dynamic route
|
||||
import { useLocalSearchParams } from 'expo-router'
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>()
|
||||
|
||||
return <UserProfile userId={id} />
|
||||
}
|
||||
|
||||
// Navigation from anywhere
|
||||
import { router } from 'expo-router'
|
||||
|
||||
// Programmatic navigation
|
||||
router.push('/profile/123')
|
||||
router.replace('/login')
|
||||
router.back()
|
||||
|
||||
// With params
|
||||
router.push({
|
||||
pathname: '/product/[id]',
|
||||
params: { id: '123', referrer: 'home' },
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern 2: Authentication Flow
|
||||
|
||||
```typescript
|
||||
// providers/AuthProvider.tsx
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { useRouter, useSegments } from 'expo-router'
|
||||
import * as SecureStore from 'expo-secure-store'
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null
|
||||
isLoading: boolean
|
||||
signIn: (credentials: Credentials) => Promise<void>
|
||||
signOut: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null)
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const segments = useSegments()
|
||||
const router = useRouter()
|
||||
|
||||
// Check authentication on mount
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
}, [])
|
||||
|
||||
// Protect routes
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
|
||||
const inAuthGroup = segments[0] === '(auth)'
|
||||
|
||||
if (!user && !inAuthGroup) {
|
||||
router.replace('/login')
|
||||
} else if (user && inAuthGroup) {
|
||||
router.replace('/(tabs)')
|
||||
}
|
||||
}, [user, segments, isLoading])
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const token = await SecureStore.getItemAsync('authToken')
|
||||
if (token) {
|
||||
const userData = await api.getUser(token)
|
||||
setUser(userData)
|
||||
}
|
||||
} catch (error) {
|
||||
await SecureStore.deleteItemAsync('authToken')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function signIn(credentials: Credentials) {
|
||||
const { token, user } = await api.login(credentials)
|
||||
await SecureStore.setItemAsync('authToken', token)
|
||||
setUser(user)
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
await SecureStore.deleteItemAsync('authToken')
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <SplashScreen />
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, isLoading, signIn, signOut }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) throw new Error('useAuth must be used within AuthProvider')
|
||||
return context
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Offline-First with React Query
|
||||
|
||||
```typescript
|
||||
// providers/QueryProvider.tsx
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
|
||||
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import NetInfo from '@react-native-community/netinfo'
|
||||
import { onlineManager } from '@tanstack/react-query'
|
||||
|
||||
// Sync online status
|
||||
onlineManager.setEventListener((setOnline) => {
|
||||
return NetInfo.addEventListener((state) => {
|
||||
setOnline(!!state.isConnected)
|
||||
})
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
gcTime: 1000 * 60 * 60 * 24, // 24 hours
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
retry: 2,
|
||||
networkMode: 'offlineFirst',
|
||||
},
|
||||
mutations: {
|
||||
networkMode: 'offlineFirst',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const asyncStoragePersister = createAsyncStoragePersister({
|
||||
storage: AsyncStorage,
|
||||
key: 'REACT_QUERY_OFFLINE_CACHE',
|
||||
})
|
||||
|
||||
export function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{ persister: asyncStoragePersister }}
|
||||
>
|
||||
{children}
|
||||
</PersistQueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// hooks/useProducts.ts
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
export function useProducts() {
|
||||
return useQuery({
|
||||
queryKey: ['products'],
|
||||
queryFn: api.getProducts,
|
||||
// Use stale data while revalidating
|
||||
placeholderData: (previousData) => previousData,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateProduct() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: api.createProduct,
|
||||
// Optimistic update
|
||||
onMutate: async (newProduct) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['products'] })
|
||||
const previous = queryClient.getQueryData(['products'])
|
||||
|
||||
queryClient.setQueryData(['products'], (old: Product[]) => [
|
||||
...old,
|
||||
{ ...newProduct, id: 'temp-' + Date.now() },
|
||||
])
|
||||
|
||||
return { previous }
|
||||
},
|
||||
onError: (err, newProduct, context) => {
|
||||
queryClient.setQueryData(['products'], context?.previous)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Native Module Integration
|
||||
|
||||
```typescript
|
||||
// services/haptics.ts
|
||||
import * as Haptics from 'expo-haptics'
|
||||
import { Platform } from 'react-native'
|
||||
|
||||
export const haptics = {
|
||||
light: () => {
|
||||
if (Platform.OS !== 'web') {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
|
||||
}
|
||||
},
|
||||
medium: () => {
|
||||
if (Platform.OS !== 'web') {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium)
|
||||
}
|
||||
},
|
||||
heavy: () => {
|
||||
if (Platform.OS !== 'web') {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)
|
||||
}
|
||||
},
|
||||
success: () => {
|
||||
if (Platform.OS !== 'web') {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
if (Platform.OS !== 'web') {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// services/biometrics.ts
|
||||
import * as LocalAuthentication from 'expo-local-authentication'
|
||||
|
||||
export async function authenticateWithBiometrics(): Promise<boolean> {
|
||||
const hasHardware = await LocalAuthentication.hasHardwareAsync()
|
||||
if (!hasHardware) return false
|
||||
|
||||
const isEnrolled = await LocalAuthentication.isEnrolledAsync()
|
||||
if (!isEnrolled) return false
|
||||
|
||||
const result = await LocalAuthentication.authenticateAsync({
|
||||
promptMessage: 'Authenticate to continue',
|
||||
fallbackLabel: 'Use passcode',
|
||||
disableDeviceFallback: false,
|
||||
})
|
||||
|
||||
return result.success
|
||||
}
|
||||
|
||||
// services/notifications.ts
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import { Platform } from 'react-native'
|
||||
import Constants from 'expo-constants'
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: true,
|
||||
}),
|
||||
})
|
||||
|
||||
export async function registerForPushNotifications() {
|
||||
let token: string | undefined
|
||||
|
||||
if (Platform.OS === 'android') {
|
||||
await Notifications.setNotificationChannelAsync('default', {
|
||||
name: 'default',
|
||||
importance: Notifications.AndroidImportance.MAX,
|
||||
vibrationPattern: [0, 250, 250, 250],
|
||||
})
|
||||
}
|
||||
|
||||
const { status: existingStatus } = await Notifications.getPermissionsAsync()
|
||||
let finalStatus = existingStatus
|
||||
|
||||
if (existingStatus !== 'granted') {
|
||||
const { status } = await Notifications.requestPermissionsAsync()
|
||||
finalStatus = status
|
||||
}
|
||||
|
||||
if (finalStatus !== 'granted') {
|
||||
return null
|
||||
}
|
||||
|
||||
const projectId = Constants.expoConfig?.extra?.eas?.projectId
|
||||
token = (await Notifications.getExpoPushTokenAsync({ projectId })).data
|
||||
|
||||
return token
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 5: Platform-Specific Code
|
||||
|
||||
```typescript
|
||||
// components/ui/Button.tsx
|
||||
import { Platform, Pressable, StyleSheet, Text, ViewStyle } from 'react-native'
|
||||
import * as Haptics from 'expo-haptics'
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated'
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
|
||||
|
||||
interface ButtonProps {
|
||||
title: string
|
||||
onPress: () => void
|
||||
variant?: 'primary' | 'secondary' | 'outline'
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function Button({
|
||||
title,
|
||||
onPress,
|
||||
variant = 'primary',
|
||||
disabled = false,
|
||||
}: ButtonProps) {
|
||||
const scale = useSharedValue(1)
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}))
|
||||
|
||||
const handlePressIn = () => {
|
||||
scale.value = withSpring(0.95)
|
||||
if (Platform.OS !== 'web') {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePressOut = () => {
|
||||
scale.value = withSpring(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatedPressable
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
disabled={disabled}
|
||||
style={[
|
||||
styles.button,
|
||||
styles[variant],
|
||||
disabled && styles.disabled,
|
||||
animatedStyle,
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.text, styles[`${variant}Text`]]}>{title}</Text>
|
||||
</AnimatedPressable>
|
||||
)
|
||||
}
|
||||
|
||||
// Platform-specific files
|
||||
// Button.ios.tsx - iOS-specific implementation
|
||||
// Button.android.tsx - Android-specific implementation
|
||||
// Button.web.tsx - Web-specific implementation
|
||||
|
||||
// Or use Platform.select
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
},
|
||||
android: {
|
||||
elevation: 4,
|
||||
},
|
||||
}),
|
||||
},
|
||||
primary: {
|
||||
backgroundColor: '#007AFF',
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: '#5856D6',
|
||||
},
|
||||
outline: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: '#007AFF',
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
text: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
primaryText: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
secondaryText: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
outlineText: {
|
||||
color: '#007AFF',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Pattern 6: Performance Optimization
|
||||
|
||||
```typescript
|
||||
// components/ProductList.tsx
|
||||
import { FlashList } from '@shopify/flash-list'
|
||||
import { memo, useCallback } from 'react'
|
||||
|
||||
interface ProductListProps {
|
||||
products: Product[]
|
||||
onProductPress: (id: string) => void
|
||||
}
|
||||
|
||||
// Memoize list item
|
||||
const ProductItem = memo(function ProductItem({
|
||||
item,
|
||||
onPress,
|
||||
}: {
|
||||
item: Product
|
||||
onPress: (id: string) => void
|
||||
}) {
|
||||
const handlePress = useCallback(() => onPress(item.id), [item.id, onPress])
|
||||
|
||||
return (
|
||||
<Pressable onPress={handlePress} style={styles.item}>
|
||||
<FastImage
|
||||
source={{ uri: item.image }}
|
||||
style={styles.image}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
<Text style={styles.title}>{item.name}</Text>
|
||||
<Text style={styles.price}>${item.price}</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})
|
||||
|
||||
export function ProductList({ products, onProductPress }: ProductListProps) {
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: Product }) => (
|
||||
<ProductItem item={item} onPress={onProductPress} />
|
||||
),
|
||||
[onProductPress]
|
||||
)
|
||||
|
||||
const keyExtractor = useCallback((item: Product) => item.id, [])
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
data={products}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemSize={100}
|
||||
// Performance optimizations
|
||||
removeClippedSubviews={true}
|
||||
maxToRenderPerBatch={10}
|
||||
windowSize={5}
|
||||
// Pull to refresh
|
||||
onRefresh={onRefresh}
|
||||
refreshing={isRefreshing}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## EAS Build & Submit
|
||||
|
||||
```json
|
||||
// eas.json
|
||||
{
|
||||
"cli": { "version": ">= 5.0.0" },
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"ios": { "simulator": true }
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"android": { "buildType": "apk" }
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {
|
||||
"ios": { "appleId": "your@email.com", "ascAppId": "123456789" },
|
||||
"android": { "serviceAccountKeyPath": "./google-services.json" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# Build commands
|
||||
eas build --platform ios --profile development
|
||||
eas build --platform android --profile preview
|
||||
eas build --platform all --profile production
|
||||
|
||||
# Submit to stores
|
||||
eas submit --platform ios
|
||||
eas submit --platform android
|
||||
|
||||
# OTA updates
|
||||
eas update --branch production --message "Bug fixes"
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Do's
|
||||
- **Use Expo** - Faster development, OTA updates, managed native code
|
||||
- **FlashList over FlatList** - Better performance for long lists
|
||||
- **Memoize components** - Prevent unnecessary re-renders
|
||||
- **Use Reanimated** - 60fps animations on native thread
|
||||
- **Test on real devices** - Simulators miss real-world issues
|
||||
|
||||
### Don'ts
|
||||
- **Don't inline styles** - Use StyleSheet.create for performance
|
||||
- **Don't fetch in render** - Use useEffect or React Query
|
||||
- **Don't ignore platform differences** - Test on both iOS and Android
|
||||
- **Don't store secrets in code** - Use environment variables
|
||||
- **Don't skip error boundaries** - Mobile crashes are unforgiving
|
||||
|
||||
## Resources
|
||||
|
||||
- [Expo Documentation](https://docs.expo.dev/)
|
||||
- [Expo Router](https://docs.expo.dev/router/introduction/)
|
||||
- [React Native Performance](https://reactnative.dev/docs/performance)
|
||||
- [FlashList](https://shopify.github.io/flash-list/)
|
||||
@@ -0,0 +1,429 @@
|
||||
---
|
||||
name: react-state-management
|
||||
description: Master modern React state management with Redux Toolkit, Zustand, Jotai, and React Query. Use when setting up global state, managing server state, or choosing between state management solutions.
|
||||
---
|
||||
|
||||
# React State Management
|
||||
|
||||
Comprehensive guide to modern React state management patterns, from local component state to global stores and server state synchronization.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- Setting up global state management in a React app
|
||||
- Choosing between Redux Toolkit, Zustand, or Jotai
|
||||
- Managing server state with React Query or SWR
|
||||
- Implementing optimistic updates
|
||||
- Debugging state-related issues
|
||||
- Migrating from legacy Redux to modern patterns
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### 1. State Categories
|
||||
|
||||
| Type | Description | Solutions |
|
||||
|------|-------------|-----------|
|
||||
| **Local State** | Component-specific, UI state | useState, useReducer |
|
||||
| **Global State** | Shared across components | Redux Toolkit, Zustand, Jotai |
|
||||
| **Server State** | Remote data, caching | React Query, SWR, RTK Query |
|
||||
| **URL State** | Route parameters, search | React Router, nuqs |
|
||||
| **Form State** | Input values, validation | React Hook Form, Formik |
|
||||
|
||||
### 2. Selection Criteria
|
||||
|
||||
```
|
||||
Small app, simple state → Zustand or Jotai
|
||||
Large app, complex state → Redux Toolkit
|
||||
Heavy server interaction → React Query + light client state
|
||||
Atomic/granular updates → Jotai
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Zustand (Simplest)
|
||||
|
||||
```typescript
|
||||
// store/useStore.ts
|
||||
import { create } from 'zustand'
|
||||
import { devtools, persist } from 'zustand/middleware'
|
||||
|
||||
interface AppState {
|
||||
user: User | null
|
||||
theme: 'light' | 'dark'
|
||||
setUser: (user: User | null) => void
|
||||
toggleTheme: () => void
|
||||
}
|
||||
|
||||
export const useStore = create<AppState>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set) => ({
|
||||
user: null,
|
||||
theme: 'light',
|
||||
setUser: (user) => set({ user }),
|
||||
toggleTheme: () => set((state) => ({
|
||||
theme: state.theme === 'light' ? 'dark' : 'light'
|
||||
})),
|
||||
}),
|
||||
{ name: 'app-storage' }
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// Usage in component
|
||||
function Header() {
|
||||
const { user, theme, toggleTheme } = useStore()
|
||||
return (
|
||||
<header className={theme}>
|
||||
{user?.name}
|
||||
<button onClick={toggleTheme}>Toggle Theme</button>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Patterns
|
||||
|
||||
### Pattern 1: Redux Toolkit with TypeScript
|
||||
|
||||
```typescript
|
||||
// store/index.ts
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
|
||||
import userReducer from './slices/userSlice'
|
||||
import cartReducer from './slices/cartSlice'
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
user: userReducer,
|
||||
cart: cartReducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: {
|
||||
ignoredActions: ['persist/PERSIST'],
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>
|
||||
export type AppDispatch = typeof store.dispatch
|
||||
|
||||
// Typed hooks
|
||||
export const useAppDispatch: () => AppDispatch = useDispatch
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
|
||||
```
|
||||
|
||||
```typescript
|
||||
// store/slices/userSlice.ts
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface UserState {
|
||||
current: User | null
|
||||
status: 'idle' | 'loading' | 'succeeded' | 'failed'
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const initialState: UserState = {
|
||||
current: null,
|
||||
status: 'idle',
|
||||
error: null,
|
||||
}
|
||||
|
||||
export const fetchUser = createAsyncThunk(
|
||||
'user/fetchUser',
|
||||
async (userId: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}`)
|
||||
if (!response.ok) throw new Error('Failed to fetch user')
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
return rejectWithValue((error as Error).message)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const userSlice = createSlice({
|
||||
name: 'user',
|
||||
initialState,
|
||||
reducers: {
|
||||
setUser: (state, action: PayloadAction<User>) => {
|
||||
state.current = action.payload
|
||||
state.status = 'succeeded'
|
||||
},
|
||||
clearUser: (state) => {
|
||||
state.current = null
|
||||
state.status = 'idle'
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchUser.pending, (state) => {
|
||||
state.status = 'loading'
|
||||
state.error = null
|
||||
})
|
||||
.addCase(fetchUser.fulfilled, (state, action) => {
|
||||
state.status = 'succeeded'
|
||||
state.current = action.payload
|
||||
})
|
||||
.addCase(fetchUser.rejected, (state, action) => {
|
||||
state.status = 'failed'
|
||||
state.error = action.payload as string
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const { setUser, clearUser } = userSlice.actions
|
||||
export default userSlice.reducer
|
||||
```
|
||||
|
||||
### Pattern 2: Zustand with Slices (Scalable)
|
||||
|
||||
```typescript
|
||||
// store/slices/createUserSlice.ts
|
||||
import { StateCreator } from 'zustand'
|
||||
|
||||
export interface UserSlice {
|
||||
user: User | null
|
||||
isAuthenticated: boolean
|
||||
login: (credentials: Credentials) => Promise<void>
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
export const createUserSlice: StateCreator<
|
||||
UserSlice & CartSlice, // Combined store type
|
||||
[],
|
||||
[],
|
||||
UserSlice
|
||||
> = (set, get) => ({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
login: async (credentials) => {
|
||||
const user = await authApi.login(credentials)
|
||||
set({ user, isAuthenticated: true })
|
||||
},
|
||||
logout: () => {
|
||||
set({ user: null, isAuthenticated: false })
|
||||
// Can access other slices
|
||||
// get().clearCart()
|
||||
},
|
||||
})
|
||||
|
||||
// store/index.ts
|
||||
import { create } from 'zustand'
|
||||
import { createUserSlice, UserSlice } from './slices/createUserSlice'
|
||||
import { createCartSlice, CartSlice } from './slices/createCartSlice'
|
||||
|
||||
type StoreState = UserSlice & CartSlice
|
||||
|
||||
export const useStore = create<StoreState>()((...args) => ({
|
||||
...createUserSlice(...args),
|
||||
...createCartSlice(...args),
|
||||
}))
|
||||
|
||||
// Selective subscriptions (prevents unnecessary re-renders)
|
||||
export const useUser = () => useStore((state) => state.user)
|
||||
export const useCart = () => useStore((state) => state.cart)
|
||||
```
|
||||
|
||||
### Pattern 3: Jotai for Atomic State
|
||||
|
||||
```typescript
|
||||
// atoms/userAtoms.ts
|
||||
import { atom } from 'jotai'
|
||||
import { atomWithStorage } from 'jotai/utils'
|
||||
|
||||
// Basic atom
|
||||
export const userAtom = atom<User | null>(null)
|
||||
|
||||
// Derived atom (computed)
|
||||
export const isAuthenticatedAtom = atom((get) => get(userAtom) !== null)
|
||||
|
||||
// Atom with localStorage persistence
|
||||
export const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light')
|
||||
|
||||
// Async atom
|
||||
export const userProfileAtom = atom(async (get) => {
|
||||
const user = get(userAtom)
|
||||
if (!user) return null
|
||||
const response = await fetch(`/api/users/${user.id}/profile`)
|
||||
return response.json()
|
||||
})
|
||||
|
||||
// Write-only atom (action)
|
||||
export const logoutAtom = atom(null, (get, set) => {
|
||||
set(userAtom, null)
|
||||
set(cartAtom, [])
|
||||
localStorage.removeItem('token')
|
||||
})
|
||||
|
||||
// Usage
|
||||
function Profile() {
|
||||
const [user] = useAtom(userAtom)
|
||||
const [, logout] = useAtom(logoutAtom)
|
||||
const [profile] = useAtom(userProfileAtom) // Suspense-enabled
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Skeleton />}>
|
||||
<ProfileContent profile={profile} onLogout={logout} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: React Query for Server State
|
||||
|
||||
```typescript
|
||||
// hooks/useUsers.ts
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
// Query keys factory
|
||||
export const userKeys = {
|
||||
all: ['users'] as const,
|
||||
lists: () => [...userKeys.all, 'list'] as const,
|
||||
list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
|
||||
details: () => [...userKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...userKeys.details(), id] as const,
|
||||
}
|
||||
|
||||
// Fetch hook
|
||||
export function useUsers(filters: UserFilters) {
|
||||
return useQuery({
|
||||
queryKey: userKeys.list(filters),
|
||||
queryFn: () => fetchUsers(filters),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 30 * 60 * 1000, // 30 minutes (formerly cacheTime)
|
||||
})
|
||||
}
|
||||
|
||||
// Single user hook
|
||||
export function useUser(id: string) {
|
||||
return useQuery({
|
||||
queryKey: userKeys.detail(id),
|
||||
queryFn: () => fetchUser(id),
|
||||
enabled: !!id, // Don't fetch if no id
|
||||
})
|
||||
}
|
||||
|
||||
// Mutation with optimistic update
|
||||
export function useUpdateUser() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateUser,
|
||||
onMutate: async (newUser) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: userKeys.detail(newUser.id) })
|
||||
|
||||
// Snapshot previous value
|
||||
const previousUser = queryClient.getQueryData(userKeys.detail(newUser.id))
|
||||
|
||||
// Optimistically update
|
||||
queryClient.setQueryData(userKeys.detail(newUser.id), newUser)
|
||||
|
||||
return { previousUser }
|
||||
},
|
||||
onError: (err, newUser, context) => {
|
||||
// Rollback on error
|
||||
queryClient.setQueryData(
|
||||
userKeys.detail(newUser.id),
|
||||
context?.previousUser
|
||||
)
|
||||
},
|
||||
onSettled: (data, error, variables) => {
|
||||
// Refetch after mutation
|
||||
queryClient.invalidateQueries({ queryKey: userKeys.detail(variables.id) })
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 5: Combining Client + Server State
|
||||
|
||||
```typescript
|
||||
// Zustand for client state
|
||||
const useUIStore = create<UIState>((set) => ({
|
||||
sidebarOpen: true,
|
||||
modal: null,
|
||||
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
|
||||
openModal: (modal) => set({ modal }),
|
||||
closeModal: () => set({ modal: null }),
|
||||
}))
|
||||
|
||||
// React Query for server state
|
||||
function Dashboard() {
|
||||
const { sidebarOpen, toggleSidebar } = useUIStore()
|
||||
const { data: users, isLoading } = useUsers({ active: true })
|
||||
const { data: stats } = useStats()
|
||||
|
||||
if (isLoading) return <DashboardSkeleton />
|
||||
|
||||
return (
|
||||
<div className={sidebarOpen ? 'with-sidebar' : ''}>
|
||||
<Sidebar open={sidebarOpen} onToggle={toggleSidebar} />
|
||||
<main>
|
||||
<StatsCards stats={stats} />
|
||||
<UserTable users={users} />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Do's
|
||||
- **Colocate state** - Keep state as close to where it's used as possible
|
||||
- **Use selectors** - Prevent unnecessary re-renders with selective subscriptions
|
||||
- **Normalize data** - Flatten nested structures for easier updates
|
||||
- **Type everything** - Full TypeScript coverage prevents runtime errors
|
||||
- **Separate concerns** - Server state (React Query) vs client state (Zustand)
|
||||
|
||||
### Don'ts
|
||||
- **Don't over-globalize** - Not everything needs to be in global state
|
||||
- **Don't duplicate server state** - Let React Query manage it
|
||||
- **Don't mutate directly** - Always use immutable updates
|
||||
- **Don't store derived data** - Compute it instead
|
||||
- **Don't mix paradigms** - Pick one primary solution per category
|
||||
|
||||
## Migration Guides
|
||||
|
||||
### From Legacy Redux to RTK
|
||||
|
||||
```typescript
|
||||
// Before (legacy Redux)
|
||||
const ADD_TODO = 'ADD_TODO'
|
||||
const addTodo = (text) => ({ type: ADD_TODO, payload: text })
|
||||
function todosReducer(state = [], action) {
|
||||
switch (action.type) {
|
||||
case ADD_TODO:
|
||||
return [...state, { text: action.payload, completed: false }]
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
// After (Redux Toolkit)
|
||||
const todosSlice = createSlice({
|
||||
name: 'todos',
|
||||
initialState: [],
|
||||
reducers: {
|
||||
addTodo: (state, action: PayloadAction<string>) => {
|
||||
// Immer allows "mutations"
|
||||
state.push({ text: action.payload, completed: false })
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Redux Toolkit Documentation](https://redux-toolkit.js.org/)
|
||||
- [Zustand GitHub](https://github.com/pmndrs/zustand)
|
||||
- [Jotai Documentation](https://jotai.org/)
|
||||
- [TanStack Query](https://tanstack.com/query)
|
||||
@@ -0,0 +1,666 @@
|
||||
---
|
||||
name: tailwind-design-system
|
||||
description: Build scalable design systems with Tailwind CSS, design tokens, component libraries, and responsive patterns. Use when creating component libraries, implementing design systems, or standardizing UI patterns.
|
||||
---
|
||||
|
||||
# Tailwind Design System
|
||||
|
||||
Build production-ready design systems with Tailwind CSS, including design tokens, component variants, responsive patterns, and accessibility.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- Creating a component library with Tailwind
|
||||
- Implementing design tokens and theming
|
||||
- Building responsive and accessible components
|
||||
- Standardizing UI patterns across a codebase
|
||||
- Migrating to or extending Tailwind CSS
|
||||
- Setting up dark mode and color schemes
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### 1. Design Token Hierarchy
|
||||
|
||||
```
|
||||
Brand Tokens (abstract)
|
||||
└── Semantic Tokens (purpose)
|
||||
└── Component Tokens (specific)
|
||||
|
||||
Example:
|
||||
blue-500 → primary → button-bg
|
||||
```
|
||||
|
||||
### 2. Component Architecture
|
||||
|
||||
```
|
||||
Base styles → Variants → Sizes → States → Overrides
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
// tailwind.config.ts
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Semantic color tokens
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
border: 'hsl(var(--border))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
}
|
||||
|
||||
export default config
|
||||
```
|
||||
|
||||
```css
|
||||
/* globals.css */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Patterns
|
||||
|
||||
### Pattern 1: CVA (Class Variance Authority) Components
|
||||
|
||||
```typescript
|
||||
// components/ui/button.tsx
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { forwardRef } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
// Base styles
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
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-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
||||
// Usage
|
||||
<Button variant="destructive" size="lg">Delete</Button>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button asChild><Link href="/home">Home</Link></Button>
|
||||
```
|
||||
|
||||
### Pattern 2: Compound Components
|
||||
|
||||
```typescript
|
||||
// components/ui/card.tsx
|
||||
import { cn } from '@/lib/utils'
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
const Card = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg border bg-card text-card-foreground shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }
|
||||
|
||||
// Usage
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account</CardTitle>
|
||||
<CardDescription>Manage your account settings</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form>...</form>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button>Save</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
```
|
||||
|
||||
### Pattern 3: Form Components
|
||||
|
||||
```typescript
|
||||
// components/ui/input.tsx
|
||||
import { forwardRef } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
error?: string
|
||||
}
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, error, ...props }, ref) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
error && 'border-destructive focus-visible:ring-destructive',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? `${props.id}-error` : undefined}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p
|
||||
id={`${props.id}-error`}
|
||||
className="mt-1 text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
// components/ui/label.tsx
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
)
|
||||
|
||||
const Label = forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<label ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
)
|
||||
)
|
||||
Label.displayName = 'Label'
|
||||
|
||||
// Usage with React Hook Form
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import * as z from 'zod'
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
})
|
||||
|
||||
function LoginForm() {
|
||||
const { register, handleSubmit, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
{...register('email')}
|
||||
error={errors.email?.message}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
{...register('password')}
|
||||
error={errors.password?.message}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full">Sign In</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Responsive Grid System
|
||||
|
||||
```typescript
|
||||
// components/ui/grid.tsx
|
||||
import { cn } from '@/lib/utils'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
const gridVariants = cva('grid', {
|
||||
variants: {
|
||||
cols: {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-1 sm:grid-cols-2',
|
||||
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
|
||||
5: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-5',
|
||||
6: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6',
|
||||
},
|
||||
gap: {
|
||||
none: 'gap-0',
|
||||
sm: 'gap-2',
|
||||
md: 'gap-4',
|
||||
lg: 'gap-6',
|
||||
xl: 'gap-8',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
cols: 3,
|
||||
gap: 'md',
|
||||
},
|
||||
})
|
||||
|
||||
interface GridProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof gridVariants> {}
|
||||
|
||||
export function Grid({ className, cols, gap, ...props }: GridProps) {
|
||||
return (
|
||||
<div className={cn(gridVariants({ cols, gap, className }))} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
// Container component
|
||||
const containerVariants = cva('mx-auto w-full px-4 sm:px-6 lg:px-8', {
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'max-w-screen-sm',
|
||||
md: 'max-w-screen-md',
|
||||
lg: 'max-w-screen-lg',
|
||||
xl: 'max-w-screen-xl',
|
||||
'2xl': 'max-w-screen-2xl',
|
||||
full: 'max-w-full',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'xl',
|
||||
},
|
||||
})
|
||||
|
||||
interface ContainerProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof containerVariants> {}
|
||||
|
||||
export function Container({ className, size, ...props }: ContainerProps) {
|
||||
return (
|
||||
<div className={cn(containerVariants({ size, className }))} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
// Usage
|
||||
<Container>
|
||||
<Grid cols={4} gap="lg">
|
||||
{products.map((product) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</Grid>
|
||||
</Container>
|
||||
```
|
||||
|
||||
### Pattern 5: Animation Utilities
|
||||
|
||||
```typescript
|
||||
// lib/animations.ts - Tailwind CSS Animate utilities
|
||||
import { cn } from './utils'
|
||||
|
||||
export const fadeIn = 'animate-in fade-in duration-300'
|
||||
export const fadeOut = 'animate-out fade-out duration-300'
|
||||
export const slideInFromTop = 'animate-in slide-in-from-top duration-300'
|
||||
export const slideInFromBottom = 'animate-in slide-in-from-bottom duration-300'
|
||||
export const slideInFromLeft = 'animate-in slide-in-from-left duration-300'
|
||||
export const slideInFromRight = 'animate-in slide-in-from-right duration-300'
|
||||
export const zoomIn = 'animate-in zoom-in-95 duration-300'
|
||||
export const zoomOut = 'animate-out zoom-out-95 duration-300'
|
||||
|
||||
// Compound animations
|
||||
export const modalEnter = cn(fadeIn, zoomIn, 'duration-200')
|
||||
export const modalExit = cn(fadeOut, zoomOut, 'duration-200')
|
||||
export const dropdownEnter = cn(fadeIn, slideInFromTop, 'duration-150')
|
||||
export const dropdownExit = cn(fadeOut, 'slide-out-to-top', 'duration-150')
|
||||
|
||||
// components/ui/dialog.tsx
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
|
||||
const DialogOverlay = forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
const DialogContent = forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
|
||||
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||
'sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
```
|
||||
|
||||
### Pattern 6: Dark Mode Implementation
|
||||
|
||||
```typescript
|
||||
// providers/ThemeProvider.tsx
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
|
||||
type Theme = 'dark' | 'light' | 'system'
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
resolvedTheme: 'dark' | 'light'
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = 'system',
|
||||
storageKey = 'theme',
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(defaultTheme)
|
||||
const [resolvedTheme, setResolvedTheme] = useState<'dark' | 'light'>('light')
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(storageKey) as Theme | null
|
||||
if (stored) setTheme(stored)
|
||||
}, [storageKey])
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
root.classList.remove('light', 'dark')
|
||||
|
||||
let resolved: 'dark' | 'light'
|
||||
|
||||
if (theme === 'system') {
|
||||
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
} else {
|
||||
resolved = theme
|
||||
}
|
||||
|
||||
root.classList.add(resolved)
|
||||
setResolvedTheme(resolved)
|
||||
}, [theme])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (newTheme: Theme) => {
|
||||
localStorage.setItem(storageKey, newTheme)
|
||||
setTheme(newTheme)
|
||||
},
|
||||
resolvedTheme,
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext)
|
||||
if (!context) throw new Error('useTheme must be used within ThemeProvider')
|
||||
return context
|
||||
}
|
||||
|
||||
// components/ThemeToggle.tsx
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { useTheme } from '@/providers/ThemeProvider'
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { resolvedTheme, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(resolvedTheme === '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" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Utility Functions
|
||||
|
||||
```typescript
|
||||
// lib/utils.ts
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
// Focus ring utility
|
||||
export const focusRing = cn(
|
||||
'focus-visible:outline-none focus-visible:ring-2',
|
||||
'focus-visible:ring-ring focus-visible:ring-offset-2'
|
||||
)
|
||||
|
||||
// Disabled utility
|
||||
export const disabled = 'disabled:pointer-events-none disabled:opacity-50'
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Do's
|
||||
- **Use CSS variables** - Enable runtime theming
|
||||
- **Compose with CVA** - Type-safe variants
|
||||
- **Use semantic colors** - `primary` not `blue-500`
|
||||
- **Forward refs** - Enable composition
|
||||
- **Add accessibility** - ARIA attributes, focus states
|
||||
|
||||
### Don'ts
|
||||
- **Don't use arbitrary values** - Extend theme instead
|
||||
- **Don't nest @apply** - Hurts readability
|
||||
- **Don't skip focus states** - Keyboard users need them
|
||||
- **Don't hardcode colors** - Use semantic tokens
|
||||
- **Don't forget dark mode** - Test both themes
|
||||
|
||||
## Resources
|
||||
|
||||
- [Tailwind CSS Documentation](https://tailwindcss.com/docs)
|
||||
- [CVA Documentation](https://cva.style/docs)
|
||||
- [shadcn/ui](https://ui.shadcn.com/)
|
||||
- [Radix Primitives](https://www.radix-ui.com/primitives)
|
||||
Reference in New Issue
Block a user