Frontend Dev Skill
React + TypeScript + Vite specialist for modern web applications.
Stack
| Layer |
Technology |
| Framework |
React 18+ |
| Language |
TypeScript |
| Build |
Vite |
| Styling |
Tailwind CSS |
| Backend |
Supabase (Auth + DB) |
| HTTP |
fetch / TanStack Query |
Project Structure
frontend/
├── src/
│ ├── components/ # Reusable UI components
│ │ ├── ui/ # Base components (Button, Input, Card)
│ │ └── features/ # Feature-specific components
│ ├── pages/ # Route pages
│ ├── hooks/ # Custom hooks
│ ├── lib/ # Utilities & config
│ │ └── supabase.ts # Supabase client
│ ├── types/ # TypeScript types
│ ├── services/ # API calls
│ └── App.tsx
├── .env.local # Environment variables
└── tailwind.config.js
Component Patterns
Basic Component Template
// components/features/SearchForm.tsx
import { useState } from 'react'
interface SearchFormProps {
onSearch: (query: string) => void
isLoading?: boolean
}
export function SearchForm({ onSearch, isLoading = false }: SearchFormProps) {
const [query, setQuery] = useState('')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (query.trim()) onSearch(query)
}
return (
<form onSubmit={handleSubmit} className="flex gap-2">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
className="flex-1 px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading || !query.trim()}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{isLoading ? 'Searching...' : 'Search'}
</button>
</form>
)
}
Custom Hook Pattern
// hooks/useSearch.ts
import { useState, useCallback } from 'react'
interface UseSearchResult<T> {
data: T | null
isLoading: boolean
error: string | null
search: (query: string) => Promise<void>
}
export function useSearch<T>(endpoint: string): UseSearchResult<T> {
const [data, setData] = useState<T | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const search = useCallback(async (query: string) => {
setIsLoading(true)
setError(null)
try {
const res = await fetch(`${endpoint}?q=${encodeURIComponent(query)}`)
if (!res.ok) throw new Error('Search failed')
setData(await res.json())
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
setIsLoading(false)
}
}, [endpoint])
return { data, isLoading, error, search }
}
Supabase Integration
Client Setup
// lib/supabase.ts
import { createClient } from '@supabase/supabase-js'
import type { Database } from '@/types/database'
export const supabase = createClient<Database>(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_ANON_KEY
)
Auth Hook
// hooks/useAuth.ts
import { useEffect, useState } from 'react'
import { supabase } from '@/lib/supabase'
import type { User } from '@supabase/supabase-js'
export function useAuth() {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => {
setUser(session?.user ?? null)
setLoading(false)
})
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => setUser(session?.user ?? null)
)
return () => subscription.unsubscribe()
}, [])
return { user, loading, isAuthenticated: !!user }
}
Data Fetching
// services/journals.ts
import { supabase } from '@/lib/supabase'
export async function getSearchHistory(userId: string) {
const { data, error } = await supabase
.from('search_history')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(10)
if (error) throw error
return data
}
Common Patterns
Loading States
function SearchResults({ isLoading, error, data }) {
if (isLoading) return <LoadingSpinner />
if (error) return <ErrorMessage message={error} />
if (!data?.length) return <EmptyState message="No results found" />
return (
<ul className="space-y-4">
{data.map(item => <ResultCard key={item.id} {...item} />)}
</ul>
)
}
Form with Validation
const [errors, setErrors] = useState<Record<string, string>>({})
const validate = (values: FormValues): boolean => {
const newErrors: Record<string, string> = {}
if (!values.title || values.title.length < 10) {
newErrors.title = 'Title must be at least 10 characters'
}
if (!values.abstract || values.abstract.length < 50) {
newErrors.abstract = 'Abstract must be at least 50 characters'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
Protected Route
// components/ProtectedRoute.tsx
import { Navigate } from 'react-router-dom'
import { useAuth } from '@/hooks/useAuth'
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth()
if (loading) return <LoadingSpinner />
if (!user) return <Navigate to="/login" replace />
return <>{children}</>
}
Tailwind Patterns
Responsive Design
// Mobile-first approach
className="
w-full // Mobile: full width
md:w-1/2 // Tablet: half width
lg:w-1/3 // Desktop: third width
p-4 md:p-6 lg:p-8 // Responsive padding
"
Common Utilities
// Card
"bg-white rounded-lg shadow-md p-6 border border-gray-200"
// Button Primary
"px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
// Button Secondary
"px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
// Input
"w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
// Error Text
"text-sm text-red-600 mt-1"
Environment Variables
# .env.local (NEVER commit!)
VITE_SUPABASE_URL=https://xxx.supabase.co
VITE_SUPABASE_ANON_KEY=eyJ...
VITE_API_URL=https://api.example.com
Access in code:
const apiUrl = import.meta.env.VITE_API_URL
Error Handling
Error Boundary (react-error-boundary)
// Install: npm install react-error-boundary
import { ErrorBoundary } from 'react-error-boundary'
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div className="p-6 bg-red-50 rounded-lg text-center">
<h2 className="text-red-800 font-semibold">Something went wrong</h2>
<p className="text-red-600 text-sm mt-2">{error.message}</p>
<button
onClick={resetErrorBoundary}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg"
>
Try again
</button>
</div>
)
}
// Usage in App.tsx
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Routes />
</ErrorBoundary>
Toast Notifications (sonner)
// Install: npm install sonner
// App.tsx - add Toaster
import { Toaster } from 'sonner'
function App() {
return (
<>
<Toaster position="top-right" richColors />
<Routes />
</>
)
}
// Usage anywhere
import { toast } from 'sonner'
toast.success('Saved successfully')
toast.error('Failed to save')
toast.loading('Searching...')
Loading States
Skeleton Component
// components/ui/Skeleton.tsx
export function Skeleton({ className }: { className?: string }) {
return (
<div className={`animate-pulse bg-slate-200 rounded ${className}`} />
)
}
// JournalCardSkeleton.tsx
export function JournalCardSkeleton() {
return (
<div className="bg-white rounded-2xl border p-6">
<Skeleton className="h-6 w-3/4 mb-4" />
<Skeleton className="h-4 w-1/2 mb-2" />
<Skeleton className="h-4 w-1/3" />
<div className="flex gap-2 mt-4">
<Skeleton className="h-6 w-16 rounded-full" />
<Skeleton className="h-6 w-20 rounded-full" />
</div>
</div>
)
}
// Usage
{isLoading ? (
<div className="space-y-4">
{[...Array(3)].map((_, i) => <JournalCardSkeleton key={i} />)}
</div>
) : (
<JournalList data={data} />
)}
Performance Optimization
When to Use memo
import { memo } from 'react'
// ✅ Use memo when:
// - Component re-renders often with same props
// - Component is expensive to render
// - Parent re-renders frequently
const JournalCard = memo(function JournalCard({ journal, onSave }: Props) {
return (/* ... */)
})
// ✅ Use useMemo for expensive calculations
const sortedJournals = useMemo(() =>
journals.sort((a, b) => b.impactFactor - a.impactFactor),
[journals]
)
// ✅ Use useCallback for stable function references
const handleSave = useCallback((id: string) => {
saveJournal(id)
}, [saveJournal])
Component Splitting Guidelines
⚠️ Split component when:
- File exceeds 200-300 lines
- Multiple unrelated responsibilities
- Reusable sub-sections exist
- Testing becomes difficult
📁 Pattern:
Search.tsx (465 lines) → Split into:
├── SearchForm.tsx (form logic)
├── SearchResults.tsx (results display)
├── useJournalSearch.ts (data fetching hook)
└── SearchFilters.tsx (filter controls)
RTL Support (Hebrew/Arabic)
Logical Properties
// ❌ Physical (breaks in RTL)
"ml-4 mr-2 pl-6 text-left border-l"
// ✅ Logical (works in RTL)
"ms-4 me-2 ps-6 text-start border-s"
// Mapping:
// ml/mr → ms/me (margin-start/end)
// pl/pr → ps/pe (padding-start/end)
// left/right → start/end
// border-l/r → border-s/e
// rounded-l/r → rounded-s/e
Common Issues & Fixes
| Issue |
Solution |
| "VITE_ env not found" |
Restart dev server after adding env vars |
| Hook called conditionally |
Hooks must be at component top level |
| Infinite re-renders |
Add dependencies to useEffect/useCallback |
| Type errors on Supabase |
Generate types with supabase gen types |
| CORS errors |
Check API URL, ensure backend allows origin |
| Component too large |
Split when >200 lines, extract hooks |
| RTL layout broken |
Use logical properties (ms/me/ps/pe) |
Recommended Packages
# UI & UX
npm install sonner # Toast notifications
npm install react-error-boundary # Error handling
# Forms
npm install react-hook-form # Form management
npm install zod # Schema validation
# Data Fetching
npm install @tanstack/react-query # Server state management
File References
- Component Examples: See
references/components.md
- Tailwind Cheatsheet: See
references/tailwind.md
- TypeScript Patterns: See
references/typescript.md
Quick Commands
# Dev server
npm run dev
# Type check
npm run type-check
# Build
npm run build
# Generate Supabase types
npx supabase gen types typescript --project-id YOUR_ID > src/types/database.ts