name: frontend-react description: React frontend patterns with TanStack, Tailwind, and Eden API client
Frontend — React + TanStack + Tailwind
Project Structure (Escalable)
apps/frontend/src/
├── components/ # Reusable UI (Radix-based)
│ ├── ui/ # Base components (Button, Input, Dialog, etc.)
│ └── forms/ # Form wrappers
├── features/ # Feature-based (DDD)
│ ├── auth/
│ │ ├── api.ts # TanStack Query hooks
│ │ ├── components/ # Auth-specific components
│ │ ├── forms.tsx # Login/Register forms
│ │ └── index.ts # Feature entry
│ ├── users/
│ │ ├── api.ts
│ │ ├── components/
│ │ └── index.ts
│ └── dashboard/
├── lib/ # Core utilities
│ ├── backend.ts # Eden client (generated)
│ ├── auth.ts # Better Auth client
│ └── query-client.ts
├── stores/ # Jotai atoms (UI state)
│ ├── ui.ts # Theme, sidebar, modals
│ └── auth.ts # Auth state if needed
├── pages/ # Route components
├── App.tsx # Root with router
└── main.tsx # Entry point
Feature Pattern (API + Components)
// features/users/api.ts - TanStack Query hooks
import { client } from '@/lib/backend'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
const usersApi = client.users
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: () => usersApi.get()
})
}
export function useUser(id: string) {
return useQuery({
queryKey: ['users', id],
queryFn: () => usersApi[':id'].get({ params: { id } }),
enabled: !!id
})
}
export function useCreateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: { email: string; name: string }) =>
usersApi.post({ body: data }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
}
})
}
export function useDeleteUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => usersApi[':id'].delete({ params: { id } }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
}
})
}
// features/users/index.ts - Export everything
export { useUsers, useUser, useCreateUser, useDeleteUser } from './api'
export { UserList } from './components/UserList'
export { UserCard } from './components/UserCard'
// features/users/components/UserList.tsx
import { useUsers, useDeleteUser } from '../api'
export function UserList() {
const { data: users, isLoading } = useUsers()
const deleteUser = useDeleteUser()
if (isLoading) return <Spinner />
return (
<ul>
{users?.map(user => (
<li key={user.id}>
{user.name}
<button onClick={() => deleteUser.mutate(user.id)}>
Delete
</button>
</li>
))}
</ul>
)
}
Better Auth Client
// lib/auth.ts
import { createAuthClient } from 'better-auth/react'
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_API_URL
})
// Hook para sesión
export function useSession() {
return authClient.useSession()
}
// Hooks de auth
export const { signIn, signUp, signOut, useListSessions } = authClient
// Usage en componente
import { useSession } from '@/lib/auth'
function Profile() {
const { data: session, isLoading } = useSession()
if (isLoading) return <Spinner />
if (!session) return <Redirect to="/login" />
return <div>Hello, {session.user.name}</div>
}
Protected Routes
// Route guard
import { useSession } from '@/lib/auth'
import { Navigate } from 'react-router'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { data: session, isLoading } = useSession()
if (isLoading) return <Spinner />
if (!session) return <Navigate to="/login" />
return <>{children}</>
}
// Router setup
import { createRouter, createRoute, rootRoute } from '@tanstack/react-router'
import { createRootRoute, createRoute } from '@tanstack/react-router'
const root = createRootRoute({ component: AppLayout })
const indexRoute = createRoute({ getParentRoute: () => root, path: '/', component: Index })
const protectedRoute = createRoute({
getParentRoute: () => root,
path: '/dashboard',
component: ProtectedDashboard
})
function ProtectedDashboard() {
return (
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
)
}
Eden Client Usage
// lib/backend.ts (generated by elysia-eden)
// Run: bunx elysia-eden
import { client } from '@/backend'
// Usage - fully typed
const { data } = await client.users.get()
const { data } = await client.users.post({ body: { email: 'x@x.com', name: 'X' } })
TanStack Query Pattern
// lib/query-client.ts
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1
}
}
})
// main.tsx
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient } from './lib/query-client'
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
Jotai for Local State
// stores/ui.ts
import { atom } from 'jotai'
export const themeAtom = atom<'light' | 'dark'>('light')
export const sidebarOpenAtom = atom(false)
export const modalAtom = atom<string | null>(null)
// Usage
import { useAtom } from 'jotai'
const [theme, setTheme] = useAtom(themeAtom)
Eden Client Usage
// lib/backend.ts (generated by elysia-eden)
import { client } from '@/backend'
export const users = client.users
export const auth = client.auth
// In component
import { users } from '@/lib/backend'
// Type-safe API call
const { data } = await users.get()
TanStack Query Pattern
// hooks/useUsers.ts
import { useQuery } from '@tanstack/react-query'
import { users } from '@/lib/backend'
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: () => users.get()
})
}
Jotai for Local State
// stores/ui.ts
import { atom } from 'jotai'
export const themeAtom = atom<'light' | 'dark'>('light')
export const sidebarOpenAtom = atom(false)
// Usage
import { useAtom } from 'jotai'
const [theme, setTheme] = useAtom(themeAtom)
TanStack Form with TypeBox
import { useForm } from '@tanstack/react-form'
import { useCreateUser } from '@/features/users/api'
// Schema compartido con backend
const userSchema = {
email: (value: string) =>
!value || !value.includes('@') ? 'Invalid email' : undefined,
name: (value: string) =>
!value || value.length < 1 ? 'Name required' : undefined
} as const
function UserForm() {
const createUser = useCreateUser()
const form = useForm({
validators: {
onChange: userSchema
},
onSubmit({ value }) {
createUser.mutate(value)
}
})
return (
<form onSubmit={form.onSubmit}>
<input {...form.register('email')} />
{form.FieldErrors.email && <span>{form.FieldErrors.email}</span>}
<input {...form.register('name')} />
<button type="submit" disabled={createUser.isPending}>
{createUser.isPending ? 'Creating...' : 'Submit'}
</button>
</form>
)
}
Tailwind + Radix Pattern
// components/ui/Dialog.tsx
import * as DialogPrimitive from '@radix-ui/react-dialog'
export function Dialog({ children, ...props }) {
return (
<DialogPrimitive.Root {...props}>
{children}
</DialogPrimitive.Root>
)
}
export function DialogTrigger({ children, ...props }) {
return <DialogPrimitive.Trigger {...props}>{children}</DialogPrimitive.Trigger>
}
export function DialogContent({ children, ...props }) {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 bg-black/50" />
<DialogPrimitive.Content className="fixed center bg-white p-6 rounded-lg">
{children}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
)
}
// Usage
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
<p>Content here</p>
</DialogContent>
</Dialog>
Animation with Framer Motion
// Lazy load for performance
import { lazy, Suspense } const AnimatedModal = lazy(() => import('./AnimatedModal'))
<Suspense fallback={<Spinner />}>
<AnimatedModal />
</Suspense>
// Simple animation
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
Content
</motion.div>
Key Patterns
- Never use fetch — Always use Eden client
- Validate forms — TypeBox schemas + TanStack Form
- Server state — TanStack Query (en
features/*/api.ts) - UI state — Jotai atoms (en
stores/) - Accessible primitives — Radix UI
- Lazy load animations — Framer Motion
Feature-First Organization
Cada feature es autocontenido:
features/users/
├── api.ts # TanStack Query hooks
├── components/ # Feature-specific components
│ ├── UserList.tsx
│ └── UserCard.tsx
├── forms.tsx # Feature forms
└── index.ts # Public exports
Beneficio: Cuando borrás un feature, es clean — solo borrás la carpeta.
Para proyectos grandes: Agregá types.ts dentro del feature si necesita tipos específicos.