init
This commit is contained in:
403
.opencode/skills/frontend-development/SKILL.md
Normal file
403
.opencode/skills/frontend-development/SKILL.md
Normal file
@@ -0,0 +1,403 @@
|
||||
---
|
||||
name: ck:frontend-development
|
||||
description: Build React/TypeScript frontends with modern patterns. Use for components, Suspense, lazy loading, useSuspenseQuery, MUI v7 styling, TanStack Router, performance optimization.
|
||||
argument-hint: "[component or feature]"
|
||||
metadata:
|
||||
author: claudekit
|
||||
version: "1.0.0"
|
||||
---
|
||||
|
||||
# Frontend Development Guidelines
|
||||
|
||||
## Purpose
|
||||
|
||||
Comprehensive guide for modern React development, emphasizing Suspense-based data fetching, lazy loading, proper file organization, and performance optimization.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- Creating new components or pages
|
||||
- Building new features
|
||||
- Fetching data with TanStack Query
|
||||
- Setting up routing with TanStack Router
|
||||
- Styling components with MUI v7
|
||||
- Performance optimization
|
||||
- Organizing frontend code
|
||||
- TypeScript best practices
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### New Component Checklist
|
||||
|
||||
Creating a component? Follow this checklist:
|
||||
|
||||
- [ ] Use `React.FC<Props>` pattern with TypeScript
|
||||
- [ ] Lazy load if heavy component: `React.lazy(() => import())`
|
||||
- [ ] Wrap in `<SuspenseLoader>` for loading states
|
||||
- [ ] Use `useSuspenseQuery` for data fetching
|
||||
- [ ] Import aliases: `@/`, `~types`, `~components`, `~features`
|
||||
- [ ] Styles: Inline if <100 lines, separate file if >100 lines
|
||||
- [ ] Use `useCallback` for event handlers passed to children
|
||||
- [ ] Default export at bottom
|
||||
- [ ] No early returns with loading spinners
|
||||
- [ ] Use `useMuiSnackbar` for user notifications
|
||||
|
||||
### New Feature Checklist
|
||||
|
||||
Creating a feature? Set up this structure:
|
||||
|
||||
- [ ] Create `features/{feature-name}/` directory
|
||||
- [ ] Create subdirectories: `api/`, `components/`, `hooks/`, `helpers/`, `types/`
|
||||
- [ ] Create API service file: `api/{feature}Api.ts`
|
||||
- [ ] Set up TypeScript types in `types/`
|
||||
- [ ] Create route in `routes/{feature-name}/index.tsx`
|
||||
- [ ] Lazy load feature components
|
||||
- [ ] Use Suspense boundaries
|
||||
- [ ] Export public API from feature `index.ts`
|
||||
|
||||
---
|
||||
|
||||
## Import Aliases Quick Reference
|
||||
|
||||
| Alias | Resolves To | Example |
|
||||
|-------|-------------|---------|
|
||||
| `@/` | `src/` | `import { apiClient } from '@/lib/apiClient'` |
|
||||
| `~types` | `src/types` | `import type { User } from '~types/user'` |
|
||||
| `~components` | `src/components` | `import { SuspenseLoader } from '~components/SuspenseLoader'` |
|
||||
| `~features` | `src/features` | `import { authApi } from '~features/auth'` |
|
||||
|
||||
Defined in: [vite.config.ts](../../vite.config.ts) lines 180-185
|
||||
|
||||
---
|
||||
|
||||
## Common Imports Cheatsheet
|
||||
|
||||
```typescript
|
||||
// React & Lazy Loading
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
const Heavy = React.lazy(() => import('./Heavy'));
|
||||
|
||||
// MUI Components
|
||||
import { Box, Paper, Typography, Button, Grid } from '@mui/material';
|
||||
import type { SxProps, Theme } from '@mui/material';
|
||||
|
||||
// TanStack Query (Suspense)
|
||||
import { useSuspenseQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
// TanStack Router
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
// Project Components
|
||||
import { SuspenseLoader } from '~components/SuspenseLoader';
|
||||
|
||||
// Hooks
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useMuiSnackbar } from '@/hooks/useMuiSnackbar';
|
||||
|
||||
// Types
|
||||
import type { Post } from '~types/post';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Topic Guides
|
||||
|
||||
### 🎨 Component Patterns
|
||||
|
||||
**Modern React components use:**
|
||||
- `React.FC<Props>` for type safety
|
||||
- `React.lazy()` for code splitting
|
||||
- `SuspenseLoader` for loading states
|
||||
- Named const + default export pattern
|
||||
|
||||
**Key Concepts:**
|
||||
- Lazy load heavy components (DataGrid, charts, editors)
|
||||
- Always wrap lazy components in Suspense
|
||||
- Use SuspenseLoader component (with fade animation)
|
||||
- Component structure: Props → Hooks → Handlers → Render → Export
|
||||
|
||||
**[📖 Complete Guide: resources/component-patterns.md](resources/component-patterns.md)**
|
||||
|
||||
---
|
||||
|
||||
### 📊 Data Fetching
|
||||
|
||||
**PRIMARY PATTERN: useSuspenseQuery**
|
||||
- Use with Suspense boundaries
|
||||
- Cache-first strategy (check grid cache before API)
|
||||
- Replaces `isLoading` checks
|
||||
- Type-safe with generics
|
||||
|
||||
**API Service Layer:**
|
||||
- Create `features/{feature}/api/{feature}Api.ts`
|
||||
- Use `apiClient` axios instance
|
||||
- Centralized methods per feature
|
||||
- Route format: `/form/route` (NOT `/api/form/route`)
|
||||
|
||||
**[📖 Complete Guide: resources/data-fetching.md](resources/data-fetching.md)**
|
||||
|
||||
---
|
||||
|
||||
### 📁 File Organization
|
||||
|
||||
**features/ vs components/:**
|
||||
- `features/`: Domain-specific (posts, comments, auth)
|
||||
- `components/`: Truly reusable (SuspenseLoader, CustomAppBar)
|
||||
|
||||
**Feature Subdirectories:**
|
||||
```
|
||||
features/
|
||||
my-feature/
|
||||
api/ # API service layer
|
||||
components/ # Feature components
|
||||
hooks/ # Custom hooks
|
||||
helpers/ # Utility functions
|
||||
types/ # TypeScript types
|
||||
```
|
||||
|
||||
**[📖 Complete Guide: resources/file-organization.md](resources/file-organization.md)**
|
||||
|
||||
---
|
||||
|
||||
### 🎨 Styling
|
||||
|
||||
**Inline vs Separate:**
|
||||
- <100 lines: Inline `const styles: Record<string, SxProps<Theme>>`
|
||||
- >100 lines: Separate `.styles.ts` file
|
||||
|
||||
**Primary Method:**
|
||||
- Use `sx` prop for MUI components
|
||||
- Type-safe with `SxProps<Theme>`
|
||||
- Theme access: `(theme) => theme.palette.primary.main`
|
||||
|
||||
**MUI v7 Grid:**
|
||||
```typescript
|
||||
<Grid size={{ xs: 12, md: 6 }}> // ✅ v7 syntax
|
||||
<Grid xs={12} md={6}> // ❌ Old syntax
|
||||
```
|
||||
|
||||
**[📖 Complete Guide: resources/styling-guide.md](resources/styling-guide.md)**
|
||||
|
||||
---
|
||||
|
||||
### 🛣️ Routing
|
||||
|
||||
**TanStack Router - Folder-Based:**
|
||||
- Directory: `routes/my-route/index.tsx`
|
||||
- Lazy load components
|
||||
- Use `createFileRoute`
|
||||
- Breadcrumb data in loader
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { lazy } from 'react';
|
||||
|
||||
const MyPage = lazy(() => import('@/features/my-feature/components/MyPage'));
|
||||
|
||||
export const Route = createFileRoute('/my-route/')({
|
||||
component: MyPage,
|
||||
loader: () => ({ crumb: 'My Route' }),
|
||||
});
|
||||
```
|
||||
|
||||
**[📖 Complete Guide: resources/routing-guide.md](resources/routing-guide.md)**
|
||||
|
||||
---
|
||||
|
||||
### ⏳ Loading & Error States
|
||||
|
||||
**CRITICAL RULE: No Early Returns**
|
||||
|
||||
```typescript
|
||||
// ❌ NEVER - Causes layout shift
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
// ✅ ALWAYS - Consistent layout
|
||||
<SuspenseLoader>
|
||||
<Content />
|
||||
</SuspenseLoader>
|
||||
```
|
||||
|
||||
**Why:** Prevents Cumulative Layout Shift (CLS), better UX
|
||||
|
||||
**Error Handling:**
|
||||
- Use `useMuiSnackbar` for user feedback
|
||||
- NEVER `react-toastify`
|
||||
- TanStack Query `onError` callbacks
|
||||
|
||||
**[📖 Complete Guide: resources/loading-and-error-states.md](resources/loading-and-error-states.md)**
|
||||
|
||||
---
|
||||
|
||||
### ⚡ Performance
|
||||
|
||||
**Optimization Patterns:**
|
||||
- `useMemo`: Expensive computations (filter, sort, map)
|
||||
- `useCallback`: Event handlers passed to children
|
||||
- `React.memo`: Expensive components
|
||||
- Debounced search (300-500ms)
|
||||
- Memory leak prevention (cleanup in useEffect)
|
||||
|
||||
**[📖 Complete Guide: resources/performance.md](resources/performance.md)**
|
||||
|
||||
---
|
||||
|
||||
### 📘 TypeScript
|
||||
|
||||
**Standards:**
|
||||
- Strict mode, no `any` type
|
||||
- Explicit return types on functions
|
||||
- Type imports: `import type { User } from '~types/user'`
|
||||
- Component prop interfaces with JSDoc
|
||||
|
||||
**[📖 Complete Guide: resources/typescript-standards.md](resources/typescript-standards.md)**
|
||||
|
||||
---
|
||||
|
||||
### 🔧 Common Patterns
|
||||
|
||||
**Covered Topics:**
|
||||
- React Hook Form with Zod validation
|
||||
- DataGrid wrapper contracts
|
||||
- Dialog component standards
|
||||
- `useAuth` hook for current user
|
||||
- Mutation patterns with cache invalidation
|
||||
|
||||
**[📖 Complete Guide: resources/common-patterns.md](resources/common-patterns.md)**
|
||||
|
||||
---
|
||||
|
||||
### 📚 Complete Examples
|
||||
|
||||
**Full working examples:**
|
||||
- Modern component with all patterns
|
||||
- Complete feature structure
|
||||
- API service layer
|
||||
- Route with lazy loading
|
||||
- Suspense + useSuspenseQuery
|
||||
- Form with validation
|
||||
|
||||
**[📖 Complete Guide: resources/complete-examples.md](resources/complete-examples.md)**
|
||||
|
||||
---
|
||||
|
||||
## Navigation Guide
|
||||
|
||||
| Need to... | Read this resource |
|
||||
|------------|-------------------|
|
||||
| Create a component | [component-patterns.md](resources/component-patterns.md) |
|
||||
| Fetch data | [data-fetching.md](resources/data-fetching.md) |
|
||||
| Organize files/folders | [file-organization.md](resources/file-organization.md) |
|
||||
| Style components | [styling-guide.md](resources/styling-guide.md) |
|
||||
| Set up routing | [routing-guide.md](resources/routing-guide.md) |
|
||||
| Handle loading/errors | [loading-and-error-states.md](resources/loading-and-error-states.md) |
|
||||
| Optimize performance | [performance.md](resources/performance.md) |
|
||||
| TypeScript types | [typescript-standards.md](resources/typescript-standards.md) |
|
||||
| Forms/Auth/DataGrid | [common-patterns.md](resources/common-patterns.md) |
|
||||
| See full examples | [complete-examples.md](resources/complete-examples.md) |
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Lazy Load Everything Heavy**: Routes, DataGrid, charts, editors
|
||||
2. **Suspense for Loading**: Use SuspenseLoader, not early returns
|
||||
3. **useSuspenseQuery**: Primary data fetching pattern for new code
|
||||
4. **Features are Organized**: api/, components/, hooks/, helpers/ subdirs
|
||||
5. **Styles Based on Size**: <100 inline, >100 separate
|
||||
6. **Import Aliases**: Use @/, ~types, ~components, ~features
|
||||
7. **No Early Returns**: Prevents layout shift
|
||||
8. **useMuiSnackbar**: For all user notifications
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
features/
|
||||
my-feature/
|
||||
api/
|
||||
myFeatureApi.ts # API service
|
||||
components/
|
||||
MyFeature.tsx # Main component
|
||||
SubComponent.tsx # Related components
|
||||
hooks/
|
||||
useMyFeature.ts # Custom hooks
|
||||
useSuspenseMyFeature.ts # Suspense hooks
|
||||
helpers/
|
||||
myFeatureHelpers.ts # Utilities
|
||||
types/
|
||||
index.ts # TypeScript types
|
||||
index.ts # Public exports
|
||||
|
||||
components/
|
||||
SuspenseLoader/
|
||||
SuspenseLoader.tsx # Reusable loader
|
||||
CustomAppBar/
|
||||
CustomAppBar.tsx # Reusable app bar
|
||||
|
||||
routes/
|
||||
my-route/
|
||||
index.tsx # Route component
|
||||
create/
|
||||
index.tsx # Nested route
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Modern Component Template (Quick Copy)
|
||||
|
||||
```typescript
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Box, Paper } from '@mui/material';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { featureApi } from '../api/featureApi';
|
||||
import type { FeatureData } from '~types/feature';
|
||||
|
||||
interface MyComponentProps {
|
||||
id: number;
|
||||
onAction?: () => void;
|
||||
}
|
||||
|
||||
export const MyComponent: React.FC<MyComponentProps> = ({ id, onAction }) => {
|
||||
const [state, setState] = useState<string>('');
|
||||
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['feature', id],
|
||||
queryFn: () => featureApi.getFeature(id),
|
||||
});
|
||||
|
||||
const handleAction = useCallback(() => {
|
||||
setState('updated');
|
||||
onAction?.();
|
||||
}, [onAction]);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
{/* Content */}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyComponent;
|
||||
```
|
||||
|
||||
For complete examples, see [resources/complete-examples.md](resources/complete-examples.md)
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- **error-tracking**: Error tracking with Sentry (applies to frontend too)
|
||||
- **backend-dev-guidelines**: Backend API patterns that frontend consumes
|
||||
|
||||
---
|
||||
|
||||
**Skill Status**: Modular structure with progressive loading for optimal context management
|
||||
@@ -0,0 +1,331 @@
|
||||
# Common Patterns
|
||||
|
||||
Frequently used patterns for forms, authentication, DataGrid, dialogs, and other common UI elements.
|
||||
|
||||
---
|
||||
|
||||
## Authentication with useAuth
|
||||
|
||||
### Getting Current User
|
||||
|
||||
```typescript
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
export const MyComponent: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
// Available properties:
|
||||
// - user.id: string
|
||||
// - user.email: string
|
||||
// - user.username: string
|
||||
// - user.roles: string[]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Logged in as: {user.email}</p>
|
||||
<p>Username: {user.username}</p>
|
||||
<p>Roles: {user.roles.join(', ')}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**NEVER make direct API calls for auth** - always use `useAuth` hook.
|
||||
|
||||
---
|
||||
|
||||
## Forms with React Hook Form
|
||||
|
||||
### Basic Form
|
||||
|
||||
```typescript
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { TextField, Button } from '@mui/material';
|
||||
import { useMuiSnackbar } from '@/hooks/useMuiSnackbar';
|
||||
|
||||
// Zod schema for validation
|
||||
const formSchema = z.object({
|
||||
username: z.string().min(3, 'Username must be at least 3 characters'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
age: z.number().min(18, 'Must be 18 or older'),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const MyForm: React.FC = () => {
|
||||
const { showSuccess, showError } = useMuiSnackbar();
|
||||
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
email: '',
|
||||
age: 18,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
try {
|
||||
await api.submitForm(data);
|
||||
showSuccess('Form submitted successfully');
|
||||
} catch (error) {
|
||||
showError('Failed to submit form');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<TextField
|
||||
{...register('username')}
|
||||
label='Username'
|
||||
error={!!errors.username}
|
||||
helperText={errors.username?.message}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('email')}
|
||||
label='Email'
|
||||
error={!!errors.email}
|
||||
helperText={errors.email?.message}
|
||||
type='email'
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('age', { valueAsNumber: true })}
|
||||
label='Age'
|
||||
error={!!errors.age}
|
||||
helperText={errors.age?.message}
|
||||
type='number'
|
||||
/>
|
||||
|
||||
<Button type='submit' variant='contained'>
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dialog Component Pattern
|
||||
|
||||
### Standard Dialog Structure
|
||||
|
||||
From BEST_PRACTICES.md - All dialogs should have:
|
||||
- Icon in title
|
||||
- Close button (X)
|
||||
- Action buttons at bottom
|
||||
|
||||
```typescript
|
||||
import { Dialog, DialogTitle, DialogContent, DialogActions, Button, IconButton } from '@mui/material';
|
||||
import { Close, Info } from '@mui/icons-material';
|
||||
|
||||
interface MyDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export const MyDialog: React.FC<MyDialogProps> = ({ open, onClose, onConfirm }) => {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth='sm' fullWidth>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Info color='primary' />
|
||||
Dialog Title
|
||||
</Box>
|
||||
<IconButton onClick={onClose} size='small'>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
{/* Content here */}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={onConfirm} variant='contained'>
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DataGrid Wrapper Pattern
|
||||
|
||||
### Wrapper Component Contract
|
||||
|
||||
From BEST_PRACTICES.md - DataGrid wrappers should accept:
|
||||
|
||||
**Required Props:**
|
||||
- `rows`: Data array
|
||||
- `columns`: Column definitions
|
||||
- Loading/error states
|
||||
|
||||
**Optional Props:**
|
||||
- Toolbar components
|
||||
- Custom actions
|
||||
- Initial state
|
||||
|
||||
```typescript
|
||||
import { DataGridPro } from '@mui/x-data-grid-pro';
|
||||
import type { GridColDef } from '@mui/x-data-grid-pro';
|
||||
|
||||
interface DataGridWrapperProps {
|
||||
rows: any[];
|
||||
columns: GridColDef[];
|
||||
loading?: boolean;
|
||||
toolbar?: React.ReactNode;
|
||||
onRowClick?: (row: any) => void;
|
||||
}
|
||||
|
||||
export const DataGridWrapper: React.FC<DataGridWrapperProps> = ({
|
||||
rows,
|
||||
columns,
|
||||
loading = false,
|
||||
toolbar,
|
||||
onRowClick,
|
||||
}) => {
|
||||
return (
|
||||
<DataGridPro
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
slots={{ toolbar: toolbar ? () => toolbar : undefined }}
|
||||
onRowClick={(params) => onRowClick?.(params.row)}
|
||||
// Standard configuration
|
||||
pagination
|
||||
pageSizeOptions={[25, 50, 100]}
|
||||
initialState={{
|
||||
pagination: { paginationModel: { pageSize: 25 } },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mutation Patterns
|
||||
|
||||
### Update with Cache Invalidation
|
||||
|
||||
```typescript
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMuiSnackbar } from '@/hooks/useMuiSnackbar';
|
||||
|
||||
export const useUpdateEntity = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { showSuccess, showError } = useMuiSnackbar();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: any }) =>
|
||||
api.updateEntity(id, data),
|
||||
|
||||
onSuccess: (result, variables) => {
|
||||
// Invalidate affected queries
|
||||
queryClient.invalidateQueries({ queryKey: ['entity', variables.id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['entities'] });
|
||||
|
||||
showSuccess('Entity updated');
|
||||
},
|
||||
|
||||
onError: () => {
|
||||
showError('Failed to update entity');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Usage
|
||||
const updateEntity = useUpdateEntity();
|
||||
|
||||
const handleSave = () => {
|
||||
updateEntity.mutate({ id: 123, data: { name: 'New Name' } });
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State Management Patterns
|
||||
|
||||
### TanStack Query for Server State (PRIMARY)
|
||||
|
||||
Use TanStack Query for **all server data**:
|
||||
- Fetching: useSuspenseQuery
|
||||
- Mutations: useMutation
|
||||
- Caching: Automatic
|
||||
- Synchronization: Built-in
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - TanStack Query for server data
|
||||
const { data: users } = useSuspenseQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => userApi.getUsers(),
|
||||
});
|
||||
```
|
||||
|
||||
### useState for UI State
|
||||
|
||||
Use `useState` for **local UI state only**:
|
||||
- Form inputs (uncontrolled)
|
||||
- Modal open/closed
|
||||
- Selected tab
|
||||
- Temporary UI flags
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - useState for UI state
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedTab, setSelectedTab] = useState(0);
|
||||
```
|
||||
|
||||
### Zustand for Global Client State (Minimal)
|
||||
|
||||
Use Zustand only for **global client state**:
|
||||
- Theme preference
|
||||
- Sidebar collapsed state
|
||||
- User preferences (not from server)
|
||||
|
||||
```typescript
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface AppState {
|
||||
sidebarOpen: boolean;
|
||||
toggleSidebar: () => void;
|
||||
}
|
||||
|
||||
export const useAppState = create<AppState>((set) => ({
|
||||
sidebarOpen: true,
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
}));
|
||||
```
|
||||
|
||||
**Avoid prop drilling** - use context or Zustand instead.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Common Patterns:**
|
||||
- ✅ useAuth hook for current user (id, email, roles, username)
|
||||
- ✅ React Hook Form + Zod for forms
|
||||
- ✅ Dialog with icon + close button
|
||||
- ✅ DataGrid wrapper contracts
|
||||
- ✅ Mutations with cache invalidation
|
||||
- ✅ TanStack Query for server state
|
||||
- ✅ useState for UI state
|
||||
- ✅ Zustand for global client state (minimal)
|
||||
|
||||
**See Also:**
|
||||
- [data-fetching.md](data-fetching.md) - TanStack Query patterns
|
||||
- [component-patterns.md](component-patterns.md) - Component structure
|
||||
- [loading-and-error-states.md](loading-and-error-states.md) - Error handling
|
||||
@@ -0,0 +1,872 @@
|
||||
# Complete Examples
|
||||
|
||||
Full working examples combining all modern patterns: React.FC, lazy loading, Suspense, useSuspenseQuery, styling, routing, and error handling.
|
||||
|
||||
---
|
||||
|
||||
## Example 1: Complete Modern Component
|
||||
|
||||
Combines: React.FC, useSuspenseQuery, cache-first, useCallback, styling, error handling
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* User profile display component
|
||||
* Demonstrates modern patterns with Suspense and TanStack Query
|
||||
*/
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { Box, Paper, Typography, Button, Avatar } from '@mui/material';
|
||||
import type { SxProps, Theme } from '@mui/material';
|
||||
import { useSuspenseQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { userApi } from '../api/userApi';
|
||||
import { useMuiSnackbar } from '@/hooks/useMuiSnackbar';
|
||||
import type { User } from '~types/user';
|
||||
|
||||
// Styles object
|
||||
const componentStyles: Record<string, SxProps<Theme>> = {
|
||||
container: {
|
||||
p: 3,
|
||||
maxWidth: 600,
|
||||
margin: '0 auto',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
mb: 3,
|
||||
},
|
||||
content: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
},
|
||||
actions: {
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
mt: 2,
|
||||
},
|
||||
};
|
||||
|
||||
interface UserProfileProps {
|
||||
userId: string;
|
||||
onUpdate?: () => void;
|
||||
}
|
||||
|
||||
export const UserProfile: React.FC<UserProfileProps> = ({ userId, onUpdate }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { showSuccess, showError } = useMuiSnackbar();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
// Suspense query - no isLoading needed!
|
||||
const { data: user } = useSuspenseQuery({
|
||||
queryKey: ['user', userId],
|
||||
queryFn: () => userApi.getUser(userId),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Update mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (updates: Partial<User>) =>
|
||||
userApi.updateUser(userId, updates),
|
||||
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['user', userId] });
|
||||
showSuccess('Profile updated');
|
||||
setIsEditing(false);
|
||||
onUpdate?.();
|
||||
},
|
||||
|
||||
onError: () => {
|
||||
showError('Failed to update profile');
|
||||
},
|
||||
});
|
||||
|
||||
// Memoized computed value
|
||||
const fullName = useMemo(() => {
|
||||
return `${user.firstName} ${user.lastName}`;
|
||||
}, [user.firstName, user.lastName]);
|
||||
|
||||
// Event handlers with useCallback
|
||||
const handleEdit = useCallback(() => {
|
||||
setIsEditing(true);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
updateMutation.mutate({
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
});
|
||||
}, [user, updateMutation]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Paper sx={componentStyles.container}>
|
||||
<Box sx={componentStyles.header}>
|
||||
<Avatar sx={{ width: 64, height: 64 }}>
|
||||
{user.firstName[0]}{user.lastName[0]}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant='h5'>{fullName}</Typography>
|
||||
<Typography color='text.secondary'>{user.email}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={componentStyles.content}>
|
||||
<Typography>Username: {user.username}</Typography>
|
||||
<Typography>Roles: {user.roles.join(', ')}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={componentStyles.actions}>
|
||||
{!isEditing ? (
|
||||
<Button variant='contained' onClick={handleEdit}>
|
||||
Edit Profile
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant='contained'
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserProfile;
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
<SuspenseLoader>
|
||||
<UserProfile userId='123' onUpdate={() => console.log('Updated')} />
|
||||
</SuspenseLoader>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example 2: Complete Feature Structure
|
||||
|
||||
Real example based on `features/posts/`:
|
||||
|
||||
```
|
||||
features/
|
||||
users/
|
||||
api/
|
||||
userApi.ts # API service layer
|
||||
components/
|
||||
UserProfile.tsx # Main component (from Example 1)
|
||||
UserList.tsx # List component
|
||||
UserBlog.tsx # Blog component
|
||||
modals/
|
||||
DeleteUserModal.tsx # Modal component
|
||||
hooks/
|
||||
useSuspenseUser.ts # Suspense query hook
|
||||
useUserMutations.ts # Mutation hooks
|
||||
useUserPermissions.ts # Feature-specific hook
|
||||
helpers/
|
||||
userHelpers.ts # Utility functions
|
||||
validation.ts # Validation logic
|
||||
types/
|
||||
index.ts # TypeScript interfaces
|
||||
index.ts # Public API exports
|
||||
```
|
||||
|
||||
### API Service (userApi.ts)
|
||||
|
||||
```typescript
|
||||
import apiClient from '@/lib/apiClient';
|
||||
import type { User, CreateUserPayload, UpdateUserPayload } from '../types';
|
||||
|
||||
export const userApi = {
|
||||
getUser: async (userId: string): Promise<User> => {
|
||||
const { data } = await apiClient.get(`/users/${userId}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
getUsers: async (): Promise<User[]> => {
|
||||
const { data } = await apiClient.get('/users');
|
||||
return data;
|
||||
},
|
||||
|
||||
createUser: async (payload: CreateUserPayload): Promise<User> => {
|
||||
const { data } = await apiClient.post('/users', payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
updateUser: async (userId: string, payload: UpdateUserPayload): Promise<User> => {
|
||||
const { data } = await apiClient.put(`/users/${userId}`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
deleteUser: async (userId: string): Promise<void> => {
|
||||
await apiClient.delete(`/users/${userId}`);
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Suspense Hook (useSuspenseUser.ts)
|
||||
|
||||
```typescript
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { userApi } from '../api/userApi';
|
||||
import type { User } from '../types';
|
||||
|
||||
export function useSuspenseUser(userId: string) {
|
||||
return useSuspenseQuery<User, Error>({
|
||||
queryKey: ['user', userId],
|
||||
queryFn: () => userApi.getUser(userId),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSuspenseUsers() {
|
||||
return useSuspenseQuery<User[], Error>({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => userApi.getUsers(),
|
||||
staleTime: 1 * 60 * 1000, // Shorter for list
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Types (types/index.ts)
|
||||
|
||||
```typescript
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
roles: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateUserPayload {
|
||||
username: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type UpdateUserPayload = Partial<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>;
|
||||
```
|
||||
|
||||
### Public Exports (index.ts)
|
||||
|
||||
```typescript
|
||||
// Export components
|
||||
export { UserProfile } from './components/UserProfile';
|
||||
export { UserList } from './components/UserList';
|
||||
|
||||
// Export hooks
|
||||
export { useSuspenseUser, useSuspenseUsers } from './hooks/useSuspenseUser';
|
||||
export { useUserMutations } from './hooks/useUserMutations';
|
||||
|
||||
// Export API
|
||||
export { userApi } from './api/userApi';
|
||||
|
||||
// Export types
|
||||
export type { User, CreateUserPayload, UpdateUserPayload } from './types';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example 3: Complete Route with Lazy Loading
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* User profile route
|
||||
* Path: /users/:userId
|
||||
*/
|
||||
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { lazy } from 'react';
|
||||
import { SuspenseLoader } from '~components/SuspenseLoader';
|
||||
|
||||
// Lazy load the UserProfile component
|
||||
const UserProfile = lazy(() =>
|
||||
import('@/features/users/components/UserProfile').then(
|
||||
(module) => ({ default: module.UserProfile })
|
||||
)
|
||||
);
|
||||
|
||||
export const Route = createFileRoute('/users/$userId')({
|
||||
component: UserProfilePage,
|
||||
loader: ({ params }) => ({
|
||||
crumb: `User ${params.userId}`,
|
||||
}),
|
||||
});
|
||||
|
||||
function UserProfilePage() {
|
||||
const { userId } = Route.useParams();
|
||||
|
||||
return (
|
||||
<SuspenseLoader>
|
||||
<UserProfile
|
||||
userId={userId}
|
||||
onUpdate={() => console.log('Profile updated')}
|
||||
/>
|
||||
</SuspenseLoader>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserProfilePage;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example 4: List with Search and Filtering
|
||||
|
||||
```typescript
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Box, TextField, List, ListItem } from '@mui/material';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { userApi } from '../api/userApi';
|
||||
|
||||
export const UserList: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [debouncedSearch] = useDebounce(searchTerm, 300);
|
||||
|
||||
const { data: users } = useSuspenseQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => userApi.getUsers(),
|
||||
});
|
||||
|
||||
// Memoized filtering
|
||||
const filteredUsers = useMemo(() => {
|
||||
if (!debouncedSearch) return users;
|
||||
|
||||
return users.filter(user =>
|
||||
user.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
|
||||
user.email.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||
);
|
||||
}, [users, debouncedSearch]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<TextField
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder='Search users...'
|
||||
fullWidth
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
<List>
|
||||
{filteredUsers.map(user => (
|
||||
<ListItem key={user.id}>
|
||||
{user.name} - {user.email}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example 5: Blog with Validation
|
||||
|
||||
```typescript
|
||||
import React from 'react';
|
||||
import { Box, TextField, Button, Paper } from '@mui/material';
|
||||
import { useBlog } from 'react-hook-blog';
|
||||
import { zodResolver } from '@hookblog/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { userApi } from '../api/userApi';
|
||||
import { useMuiSnackbar } from '@/hooks/useMuiSnackbar';
|
||||
|
||||
const userSchema = z.object({
|
||||
username: z.string().min(3).max(50),
|
||||
email: z.string().email(),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
});
|
||||
|
||||
type UserBlogData = z.infer<typeof userSchema>;
|
||||
|
||||
interface CreateUserBlogProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export const CreateUserBlog: React.FC<CreateUserBlogProps> = ({ onSuccess }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { showSuccess, showError } = useMuiSnackbar();
|
||||
|
||||
const { register, handleSubmit, blogState: { errors }, reset } = useBlog<UserBlogData>({
|
||||
resolver: zodResolver(userSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
email: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: UserBlogData) => userApi.createUser(data),
|
||||
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
showSuccess('User created successfully');
|
||||
reset();
|
||||
onSuccess?.();
|
||||
},
|
||||
|
||||
onError: () => {
|
||||
showError('Failed to create user');
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: UserBlogData) => {
|
||||
createMutation.mutate(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3, maxWidth: 500 }}>
|
||||
<blog onSubmit={handleSubmit(onSubmit)}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
{...register('username')}
|
||||
label='Username'
|
||||
error={!!errors.username}
|
||||
helperText={errors.username?.message}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('email')}
|
||||
label='Email'
|
||||
type='email'
|
||||
error={!!errors.email}
|
||||
helperText={errors.email?.message}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('firstName')}
|
||||
label='First Name'
|
||||
error={!!errors.firstName}
|
||||
helperText={errors.firstName?.message}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('lastName')}
|
||||
label='Last Name'
|
||||
error={!!errors.lastName}
|
||||
helperText={errors.lastName?.message}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
variant='contained'
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? 'Creating...' : 'Create User'}
|
||||
</Button>
|
||||
</Box>
|
||||
</blog>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateUserBlog;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example 2: Parent Container with Lazy Loading
|
||||
|
||||
```typescript
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { SuspenseLoader } from '~components/SuspenseLoader';
|
||||
|
||||
// Lazy load heavy components
|
||||
const UserList = React.lazy(() => import('./UserList'));
|
||||
const UserStats = React.lazy(() => import('./UserStats'));
|
||||
const ActivityFeed = React.lazy(() => import('./ActivityFeed'));
|
||||
|
||||
export const UserDashboard: React.FC = () => {
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<SuspenseLoader>
|
||||
<UserStats />
|
||||
</SuspenseLoader>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, mt: 2 }}>
|
||||
<Box sx={{ flex: 2 }}>
|
||||
<SuspenseLoader>
|
||||
<UserList />
|
||||
</SuspenseLoader>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<SuspenseLoader>
|
||||
<ActivityFeed />
|
||||
</SuspenseLoader>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserDashboard;
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Each section loads independently
|
||||
- User sees partial content sooner
|
||||
- Better perceived perblogance
|
||||
|
||||
---
|
||||
|
||||
## Example 3: Cache-First Strategy Implementation
|
||||
|
||||
Complete example based on useSuspensePost.ts:
|
||||
|
||||
```typescript
|
||||
import { useSuspenseQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { postApi } from '../api/postApi';
|
||||
import type { Post } from '../types';
|
||||
|
||||
/**
|
||||
* Smart post hook with cache-first strategy
|
||||
* Reuses data from grid cache when available
|
||||
*/
|
||||
export function useSuspensePost(blogId: number, postId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useSuspenseQuery<Post, Error>({
|
||||
queryKey: ['post', blogId, postId],
|
||||
queryFn: async () => {
|
||||
// Strategy 1: Check grid cache first (avoids API call)
|
||||
const gridCache = queryClient.getQueryData<{ rows: Post[] }>([
|
||||
'posts-v2',
|
||||
blogId,
|
||||
'summary'
|
||||
]) || queryClient.getQueryData<{ rows: Post[] }>([
|
||||
'posts-v2',
|
||||
blogId,
|
||||
'flat'
|
||||
]);
|
||||
|
||||
if (gridCache?.rows) {
|
||||
const cached = gridCache.rows.find(
|
||||
(row) => row.S_ID === postId
|
||||
);
|
||||
|
||||
if (cached) {
|
||||
return cached; // Return from cache - no API call!
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Not in cache, fetch from API
|
||||
return postApi.getPost(blogId, postId);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Fresh for 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // Cache for 10 minutes
|
||||
refetchOnWindowFocus: false, // Don't refetch on focus
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Why this pattern:**
|
||||
- Checks grid cache before API
|
||||
- Instant data if user came from grid
|
||||
- Falls back to API if not cached
|
||||
- Configurable cache times
|
||||
|
||||
---
|
||||
|
||||
## Example 4: Complete Route File
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Project catalog route
|
||||
* Path: /project-catalog
|
||||
*/
|
||||
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { lazy } from 'react';
|
||||
|
||||
// Lazy load the PostTable component
|
||||
const PostTable = lazy(() =>
|
||||
import('@/features/posts/components/PostTable').then(
|
||||
(module) => ({ default: module.PostTable })
|
||||
)
|
||||
);
|
||||
|
||||
// Route constants
|
||||
const PROJECT_CATALOG_FORM_ID = 744;
|
||||
const PROJECT_CATALOG_PROJECT_ID = 225;
|
||||
|
||||
export const Route = createFileRoute('/project-catalog/')({
|
||||
component: ProjectCatalogPage,
|
||||
loader: () => ({
|
||||
crumb: 'Projects', // Breadcrumb title
|
||||
}),
|
||||
});
|
||||
|
||||
function ProjectCatalogPage() {
|
||||
return (
|
||||
<PostTable
|
||||
blogId={PROJECT_CATALOG_FORM_ID}
|
||||
projectId={PROJECT_CATALOG_PROJECT_ID}
|
||||
tableType='active_projects'
|
||||
title='Blog Dashboard'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProjectCatalogPage;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example 5: Dialog with Blog
|
||||
|
||||
```typescript
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
Box,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import { Close, PersonAdd } from '@mui/icons-material';
|
||||
import { useBlog } from 'react-hook-blog';
|
||||
import { zodResolver } from '@hookblog/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
const blogSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
type BlogData = z.infer<typeof blogSchema>;
|
||||
|
||||
interface AddUserDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: BlogData) => Promise<void>;
|
||||
}
|
||||
|
||||
export const AddUserDialog: React.FC<AddUserDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const { register, handleSubmit, blogState: { errors }, reset } = useBlog<BlogData>({
|
||||
resolver: zodResolver(blogSchema),
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleBlogSubmit = async (data: BlogData) => {
|
||||
await onSubmit(data);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth='sm' fullWidth>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<PersonAdd color='primary' />
|
||||
Add User
|
||||
</Box>
|
||||
<IconButton onClick={handleClose} size='small'>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<blog onSubmit={handleSubmit(handleBlogSubmit)}>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
{...register('name')}
|
||||
label='Name'
|
||||
error={!!errors.name}
|
||||
helperText={errors.name?.message}
|
||||
fullWidth
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register('email')}
|
||||
label='Email'
|
||||
type='email'
|
||||
error={!!errors.email}
|
||||
helperText={errors.email?.message}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
<Button type='submit' variant='contained'>
|
||||
Add User
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</blog>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example 6: Parallel Data Fetching
|
||||
|
||||
```typescript
|
||||
import React from 'react';
|
||||
import { Box, Grid, Paper } from '@mui/material';
|
||||
import { useSuspenseQueries } from '@tanstack/react-query';
|
||||
import { userApi } from '../api/userApi';
|
||||
import { statsApi } from '../api/statsApi';
|
||||
import { activityApi } from '../api/activityApi';
|
||||
|
||||
export const Dashboard: React.FC = () => {
|
||||
// Fetch all data in parallel with Suspense
|
||||
const [statsQuery, usersQuery, activityQuery] = useSuspenseQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ['stats'],
|
||||
queryFn: () => statsApi.getStats(),
|
||||
},
|
||||
{
|
||||
queryKey: ['users', 'active'],
|
||||
queryFn: () => userApi.getActiveUsers(),
|
||||
},
|
||||
{
|
||||
queryKey: ['activity', 'recent'],
|
||||
queryFn: () => activityApi.getRecent(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<h3>Stats</h3>
|
||||
<p>Total: {statsQuery.data.total}</p>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<h3>Active Users</h3>
|
||||
<p>Count: {usersQuery.data.length}</p>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<h3>Recent Activity</h3>
|
||||
<p>Events: {activityQuery.data.length}</p>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Usage with Suspense
|
||||
<SuspenseLoader>
|
||||
<Dashboard />
|
||||
</SuspenseLoader>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example 7: Optimistic Update
|
||||
|
||||
```typescript
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { User } from '../types';
|
||||
|
||||
export const useToggleUserStatus = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (userId: string) => userApi.toggleStatus(userId),
|
||||
|
||||
// Optimistic update
|
||||
onMutate: async (userId) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: ['users'] });
|
||||
|
||||
// Snapshot previous value
|
||||
const previousUsers = queryClient.getQueryData<User[]>(['users']);
|
||||
|
||||
// Optimistically update UI
|
||||
queryClient.setQueryData<User[]>(['users'], (old) => {
|
||||
return old?.map(user =>
|
||||
user.id === userId
|
||||
? { ...user, active: !user.active }
|
||||
: user
|
||||
) || [];
|
||||
});
|
||||
|
||||
return { previousUsers };
|
||||
},
|
||||
|
||||
// Rollback on error
|
||||
onError: (err, userId, context) => {
|
||||
queryClient.setQueryData(['users'], context?.previousUsers);
|
||||
},
|
||||
|
||||
// Refetch after mutation
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Key Takeaways:**
|
||||
|
||||
1. **Component Pattern**: React.FC + lazy + Suspense + useSuspenseQuery
|
||||
2. **Feature Structure**: Organized subdirectories (api/, components/, hooks/, etc.)
|
||||
3. **Routing**: Folder-based with lazy loading
|
||||
4. **Data Fetching**: useSuspenseQuery with cache-first strategy
|
||||
5. **Blogs**: React Hook Blog + Zod validation
|
||||
6. **Error Handling**: useMuiSnackbar + onError callbacks
|
||||
7. **Perblogance**: useMemo, useCallback, React.memo, debouncing
|
||||
8. **Styling**: Inline <100 lines, sx prop, MUI v7 syntax
|
||||
|
||||
**See other resources for detailed explanations of each pattern.**
|
||||
@@ -0,0 +1,502 @@
|
||||
# Component Patterns
|
||||
|
||||
Modern React component architecture for the application emphasizing type safety, lazy loading, and Suspense boundaries.
|
||||
|
||||
---
|
||||
|
||||
## React.FC Pattern (PREFERRED)
|
||||
|
||||
### Why React.FC
|
||||
|
||||
All components use the `React.FC<Props>` pattern for:
|
||||
- Explicit type safety for props
|
||||
- Consistent component signatures
|
||||
- Clear prop interface documentation
|
||||
- Better IDE autocomplete
|
||||
|
||||
### Basic Pattern
|
||||
|
||||
```typescript
|
||||
import React from 'react';
|
||||
|
||||
interface MyComponentProps {
|
||||
/** User ID to display */
|
||||
userId: number;
|
||||
/** Optional callback when action occurs */
|
||||
onAction?: () => void;
|
||||
}
|
||||
|
||||
export const MyComponent: React.FC<MyComponentProps> = ({ userId, onAction }) => {
|
||||
return (
|
||||
<div>
|
||||
User: {userId}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyComponent;
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Props interface defined separately with JSDoc comments
|
||||
- `React.FC<Props>` provides type safety
|
||||
- Destructure props in parameters
|
||||
- Default export at bottom
|
||||
|
||||
---
|
||||
|
||||
## Lazy Loading Pattern
|
||||
|
||||
### When to Lazy Load
|
||||
|
||||
Lazy load components that are:
|
||||
- Heavy (DataGrid, charts, rich text editors)
|
||||
- Route-level components
|
||||
- Modal/dialog content (not shown initially)
|
||||
- Below-the-fold content
|
||||
|
||||
### How to Lazy Load
|
||||
|
||||
```typescript
|
||||
import React from 'react';
|
||||
|
||||
// Lazy load heavy component
|
||||
const PostDataGrid = React.lazy(() =>
|
||||
import('./grids/PostDataGrid')
|
||||
);
|
||||
|
||||
// For named exports
|
||||
const MyComponent = React.lazy(() =>
|
||||
import('./MyComponent').then(module => ({
|
||||
default: module.MyComponent
|
||||
}))
|
||||
);
|
||||
```
|
||||
|
||||
**Example from PostTable.tsx:**
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Main post table container component
|
||||
*/
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Box, Paper } from '@mui/material';
|
||||
|
||||
// Lazy load PostDataGrid to optimize bundle size
|
||||
const PostDataGrid = React.lazy(() => import('./grids/PostDataGrid'));
|
||||
|
||||
import { SuspenseLoader } from '~components/SuspenseLoader';
|
||||
|
||||
export const PostTable: React.FC<PostTableProps> = ({ formId }) => {
|
||||
return (
|
||||
<Box>
|
||||
<SuspenseLoader>
|
||||
<PostDataGrid formId={formId} />
|
||||
</SuspenseLoader>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostTable;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Suspense Boundaries
|
||||
|
||||
### SuspenseLoader Component
|
||||
|
||||
**Import:**
|
||||
```typescript
|
||||
import { SuspenseLoader } from '~components/SuspenseLoader';
|
||||
// Or
|
||||
import { SuspenseLoader } from '@/components/SuspenseLoader';
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
<SuspenseLoader>
|
||||
<LazyLoadedComponent />
|
||||
</SuspenseLoader>
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Shows loading indicator while lazy component loads
|
||||
- Smooth fade-in animation
|
||||
- Consistent loading experience
|
||||
- Prevents layout shift
|
||||
|
||||
### Where to Place Suspense Boundaries
|
||||
|
||||
**Route Level:**
|
||||
```typescript
|
||||
// routes/my-route/index.tsx
|
||||
const MyPage = lazy(() => import('@/features/my-feature/components/MyPage'));
|
||||
|
||||
function Route() {
|
||||
return (
|
||||
<SuspenseLoader>
|
||||
<MyPage />
|
||||
</SuspenseLoader>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Component Level:**
|
||||
```typescript
|
||||
function ParentComponent() {
|
||||
return (
|
||||
<Box>
|
||||
<Header />
|
||||
<SuspenseLoader>
|
||||
<HeavyDataGrid />
|
||||
</SuspenseLoader>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Multiple Boundaries:**
|
||||
```typescript
|
||||
function Page() {
|
||||
return (
|
||||
<Box>
|
||||
<SuspenseLoader>
|
||||
<HeaderSection />
|
||||
</SuspenseLoader>
|
||||
|
||||
<SuspenseLoader>
|
||||
<MainContent />
|
||||
</SuspenseLoader>
|
||||
|
||||
<SuspenseLoader>
|
||||
<Sidebar />
|
||||
</SuspenseLoader>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Each section loads independently, better UX.
|
||||
|
||||
---
|
||||
|
||||
## Component Structure Template
|
||||
|
||||
### Recommended Order
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Component description
|
||||
* What it does, when to use it
|
||||
*/
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { Box, Paper, Button } from '@mui/material';
|
||||
import type { SxProps, Theme } from '@mui/material';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
|
||||
// Feature imports
|
||||
import { myFeatureApi } from '../api/myFeatureApi';
|
||||
import type { MyData } from '~types/myData';
|
||||
|
||||
// Component imports
|
||||
import { SuspenseLoader } from '~components/SuspenseLoader';
|
||||
|
||||
// Hooks
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useMuiSnackbar } from '@/hooks/useMuiSnackbar';
|
||||
|
||||
// 1. PROPS INTERFACE (with JSDoc)
|
||||
interface MyComponentProps {
|
||||
/** The ID of the entity to display */
|
||||
entityId: number;
|
||||
/** Optional callback when action completes */
|
||||
onComplete?: () => void;
|
||||
/** Display mode */
|
||||
mode?: 'view' | 'edit';
|
||||
}
|
||||
|
||||
// 2. STYLES (if inline and <100 lines)
|
||||
const componentStyles: Record<string, SxProps<Theme>> = {
|
||||
container: {
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
header: {
|
||||
mb: 2,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
};
|
||||
|
||||
// 3. COMPONENT DEFINITION
|
||||
export const MyComponent: React.FC<MyComponentProps> = ({
|
||||
entityId,
|
||||
onComplete,
|
||||
mode = 'view',
|
||||
}) => {
|
||||
// 4. HOOKS (in this order)
|
||||
// - Context hooks first
|
||||
const { user } = useAuth();
|
||||
const { showSuccess, showError } = useMuiSnackbar();
|
||||
|
||||
// - Data fetching
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['myEntity', entityId],
|
||||
queryFn: () => myFeatureApi.getEntity(entityId),
|
||||
});
|
||||
|
||||
// - Local state
|
||||
const [selectedItem, setSelectedItem] = useState<string | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(mode === 'edit');
|
||||
|
||||
// - Memoized values
|
||||
const filteredData = useMemo(() => {
|
||||
return data.filter(item => item.active);
|
||||
}, [data]);
|
||||
|
||||
// - Effects
|
||||
useEffect(() => {
|
||||
// Setup
|
||||
return () => {
|
||||
// Cleanup
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 5. EVENT HANDLERS (with useCallback)
|
||||
const handleItemSelect = useCallback((itemId: string) => {
|
||||
setSelectedItem(itemId);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
try {
|
||||
await myFeatureApi.updateEntity(entityId, { /* data */ });
|
||||
showSuccess('Entity updated successfully');
|
||||
onComplete?.();
|
||||
} catch (error) {
|
||||
showError('Failed to update entity');
|
||||
}
|
||||
}, [entityId, onComplete, showSuccess, showError]);
|
||||
|
||||
// 6. RENDER
|
||||
return (
|
||||
<Box sx={componentStyles.container}>
|
||||
<Box sx={componentStyles.header}>
|
||||
<h2>My Component</h2>
|
||||
<Button onClick={handleSave}>Save</Button>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ p: 2 }}>
|
||||
{filteredData.map(item => (
|
||||
<div key={item.id}>{item.name}</div>
|
||||
))}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// 7. EXPORT (default export at bottom)
|
||||
export default MyComponent;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Separation
|
||||
|
||||
### When to Split Components
|
||||
|
||||
**Split into multiple components when:**
|
||||
- Component exceeds 300 lines
|
||||
- Multiple distinct responsibilities
|
||||
- Reusable sections
|
||||
- Complex nested JSX
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
// ❌ AVOID - Monolithic
|
||||
function MassiveComponent() {
|
||||
// 500+ lines
|
||||
// Search logic
|
||||
// Filter logic
|
||||
// Grid logic
|
||||
// Action panel logic
|
||||
}
|
||||
|
||||
// ✅ PREFERRED - Modular
|
||||
function ParentContainer() {
|
||||
return (
|
||||
<Box>
|
||||
<SearchAndFilter onFilter={handleFilter} />
|
||||
<DataGrid data={filteredData} />
|
||||
<ActionPanel onAction={handleAction} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### When to Keep Together
|
||||
|
||||
**Keep in same file when:**
|
||||
- Component < 200 lines
|
||||
- Tightly coupled logic
|
||||
- Not reusable elsewhere
|
||||
- Simple presentation component
|
||||
|
||||
---
|
||||
|
||||
## Export Patterns
|
||||
|
||||
### Named Const + Default Export (PREFERRED)
|
||||
|
||||
```typescript
|
||||
export const MyComponent: React.FC<Props> = ({ ... }) => {
|
||||
// Component logic
|
||||
};
|
||||
|
||||
export default MyComponent;
|
||||
```
|
||||
|
||||
**Why:**
|
||||
- Named export for testing/refactoring
|
||||
- Default export for lazy loading convenience
|
||||
- Both options available to consumers
|
||||
|
||||
### Lazy Loading Named Exports
|
||||
|
||||
```typescript
|
||||
const MyComponent = React.lazy(() =>
|
||||
import('./MyComponent').then(module => ({
|
||||
default: module.MyComponent
|
||||
}))
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Communication
|
||||
|
||||
### Props Down, Events Up
|
||||
|
||||
```typescript
|
||||
// Parent
|
||||
function Parent() {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<Child
|
||||
data={data} // Props down
|
||||
onSelect={setSelectedId} // Events up
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Child
|
||||
interface ChildProps {
|
||||
data: Data[];
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
export const Child: React.FC<ChildProps> = ({ data, onSelect }) => {
|
||||
return (
|
||||
<div onClick={() => onSelect(data[0].id)}>
|
||||
{/* Content */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Avoid Prop Drilling
|
||||
|
||||
**Use context for deep nesting:**
|
||||
```typescript
|
||||
// ❌ AVOID - Prop drilling 5+ levels
|
||||
<A prop={x}>
|
||||
<B prop={x}>
|
||||
<C prop={x}>
|
||||
<D prop={x}>
|
||||
<E prop={x} /> // Finally uses it here
|
||||
</D>
|
||||
</C>
|
||||
</B>
|
||||
</A>
|
||||
|
||||
// ✅ PREFERRED - Context or TanStack Query
|
||||
const MyContext = createContext<MyData | null>(null);
|
||||
|
||||
function Provider({ children }) {
|
||||
const { data } = useSuspenseQuery({ ... });
|
||||
return <MyContext.Provider value={data}>{children}</MyContext.Provider>;
|
||||
}
|
||||
|
||||
function DeepChild() {
|
||||
const data = useContext(MyContext);
|
||||
// Use data directly
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Compound Components
|
||||
|
||||
```typescript
|
||||
// Card.tsx
|
||||
export const Card: React.FC<CardProps> & {
|
||||
Header: typeof CardHeader;
|
||||
Body: typeof CardBody;
|
||||
Footer: typeof CardFooter;
|
||||
} = ({ children }) => {
|
||||
return <Paper>{children}</Paper>;
|
||||
};
|
||||
|
||||
Card.Header = CardHeader;
|
||||
Card.Body = CardBody;
|
||||
Card.Footer = CardFooter;
|
||||
|
||||
// Usage
|
||||
<Card>
|
||||
<Card.Header>Title</Card.Header>
|
||||
<Card.Body>Content</Card.Body>
|
||||
<Card.Footer>Actions</Card.Footer>
|
||||
</Card>
|
||||
```
|
||||
|
||||
### Render Props (Rare, but useful)
|
||||
|
||||
```typescript
|
||||
interface DataProviderProps {
|
||||
children: (data: Data) => React.ReactNode;
|
||||
}
|
||||
|
||||
export const DataProvider: React.FC<DataProviderProps> = ({ children }) => {
|
||||
const { data } = useSuspenseQuery({ ... });
|
||||
return <>{children(data)}</>;
|
||||
};
|
||||
|
||||
// Usage
|
||||
<DataProvider>
|
||||
{(data) => <Display data={data} />}
|
||||
</DataProvider>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Modern Component Recipe:**
|
||||
1. `React.FC<Props>` with TypeScript
|
||||
2. Lazy load if heavy: `React.lazy(() => import())`
|
||||
3. Wrap in `<SuspenseLoader>` for loading
|
||||
4. Use `useSuspenseQuery` for data
|
||||
5. Import aliases (@/, ~types, ~components)
|
||||
6. Event handlers with `useCallback`
|
||||
7. Default export at bottom
|
||||
8. No early returns for loading states
|
||||
|
||||
**See Also:**
|
||||
- [data-fetching.md](data-fetching.md) - useSuspenseQuery details
|
||||
- [loading-and-error-states.md](loading-and-error-states.md) - Suspense best practices
|
||||
- [complete-examples.md](complete-examples.md) - Full working examples
|
||||
767
.opencode/skills/frontend-development/resources/data-fetching.md
Normal file
767
.opencode/skills/frontend-development/resources/data-fetching.md
Normal file
@@ -0,0 +1,767 @@
|
||||
# Data Fetching Patterns
|
||||
|
||||
Modern data fetching using TanStack Query with Suspense boundaries, cache-first strategies, and centralized API services.
|
||||
|
||||
---
|
||||
|
||||
## PRIMARY PATTERN: useSuspenseQuery
|
||||
|
||||
### Why useSuspenseQuery?
|
||||
|
||||
For **all new components**, use `useSuspenseQuery` instead of regular `useQuery`:
|
||||
|
||||
**Benefits:**
|
||||
- No `isLoading` checks needed
|
||||
- Integrates with Suspense boundaries
|
||||
- Cleaner component code
|
||||
- Consistent loading UX
|
||||
- Better error handling with error boundaries
|
||||
|
||||
### Basic Pattern
|
||||
|
||||
```typescript
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { myFeatureApi } from '../api/myFeatureApi';
|
||||
|
||||
export const MyComponent: React.FC<Props> = ({ id }) => {
|
||||
// No isLoading - Suspense handles it!
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['myEntity', id],
|
||||
queryFn: () => myFeatureApi.getEntity(id),
|
||||
});
|
||||
|
||||
// data is ALWAYS defined here (not undefined | Data)
|
||||
return <div>{data.name}</div>;
|
||||
};
|
||||
|
||||
// Wrap in Suspense boundary
|
||||
<SuspenseLoader>
|
||||
<MyComponent id={123} />
|
||||
</SuspenseLoader>
|
||||
```
|
||||
|
||||
### useSuspenseQuery vs useQuery
|
||||
|
||||
| Feature | useSuspenseQuery | useQuery |
|
||||
|---------|------------------|----------|
|
||||
| Loading state | Handled by Suspense | Manual `isLoading` check |
|
||||
| Data type | Always defined | `Data \| undefined` |
|
||||
| Use with | Suspense boundaries | Traditional components |
|
||||
| Recommended for | **NEW components** | Legacy code only |
|
||||
| Error handling | Error boundaries | Manual error state |
|
||||
|
||||
**When to use regular useQuery:**
|
||||
- Maintaining legacy code
|
||||
- Very simple cases without Suspense
|
||||
- Polling with background updates
|
||||
|
||||
**For new components: Always prefer useSuspenseQuery**
|
||||
|
||||
---
|
||||
|
||||
## Cache-First Strategy
|
||||
|
||||
### Cache-First Pattern Example
|
||||
|
||||
**Smart caching** reduces API calls by checking React Query cache first:
|
||||
|
||||
```typescript
|
||||
import { useSuspenseQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { postApi } from '../api/postApi';
|
||||
|
||||
export function useSuspensePost(postId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useSuspenseQuery({
|
||||
queryKey: ['post', postId],
|
||||
queryFn: async () => {
|
||||
// Strategy 1: Try to get from list cache first
|
||||
const cachedListData = queryClient.getQueryData<{ posts: Post[] }>([
|
||||
'posts',
|
||||
'list'
|
||||
]);
|
||||
|
||||
if (cachedListData?.posts) {
|
||||
const cachedPost = cachedListData.posts.find(
|
||||
(post) => post.id === postId
|
||||
);
|
||||
|
||||
if (cachedPost) {
|
||||
return cachedPost; // Return from cache!
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Not in cache, fetch from API
|
||||
return postApi.getPost(postId);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Consider fresh for 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // Keep in cache for 10 minutes
|
||||
refetchOnWindowFocus: false, // Don't refetch on focus
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Check grid/list cache before API call
|
||||
- Avoids redundant requests
|
||||
- `staleTime`: How long data is considered fresh
|
||||
- `gcTime`: How long unused data stays in cache
|
||||
- `refetchOnWindowFocus: false`: User preference
|
||||
|
||||
---
|
||||
|
||||
## Parallel Data Fetching
|
||||
|
||||
### useSuspenseQueries
|
||||
|
||||
When fetching multiple independent resources:
|
||||
|
||||
```typescript
|
||||
import { useSuspenseQueries } from '@tanstack/react-query';
|
||||
|
||||
export const MyComponent: React.FC = () => {
|
||||
const [userQuery, settingsQuery, preferencesQuery] = useSuspenseQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ['user'],
|
||||
queryFn: () => userApi.getCurrentUser(),
|
||||
},
|
||||
{
|
||||
queryKey: ['settings'],
|
||||
queryFn: () => settingsApi.getSettings(),
|
||||
},
|
||||
{
|
||||
queryKey: ['preferences'],
|
||||
queryFn: () => preferencesApi.getPreferences(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// All data available, Suspense handles loading
|
||||
const user = userQuery.data;
|
||||
const settings = settingsQuery.data;
|
||||
const preferences = preferencesQuery.data;
|
||||
|
||||
return <Display user={user} settings={settings} prefs={preferences} />;
|
||||
};
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- All queries in parallel
|
||||
- Single Suspense boundary
|
||||
- Type-safe results
|
||||
|
||||
---
|
||||
|
||||
## Query Keys Organization
|
||||
|
||||
### Naming Convention
|
||||
|
||||
```typescript
|
||||
// Entity list
|
||||
['entities', blogId]
|
||||
['entities', blogId, 'summary'] // With view mode
|
||||
['entities', blogId, 'flat']
|
||||
|
||||
// Single entity
|
||||
['entity', blogId, entityId]
|
||||
|
||||
// Related data
|
||||
['entity', entityId, 'history']
|
||||
['entity', entityId, 'comments']
|
||||
|
||||
// User-specific
|
||||
['user', userId, 'profile']
|
||||
['user', userId, 'permissions']
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Start with entity name (plural for lists, singular for one)
|
||||
- Include IDs for specificity
|
||||
- Add view mode / relationship at end
|
||||
- Consistent across app
|
||||
|
||||
### Query Key Examples
|
||||
|
||||
```typescript
|
||||
// From useSuspensePost.ts
|
||||
queryKey: ['post', blogId, postId]
|
||||
queryKey: ['posts-v2', blogId, 'summary']
|
||||
|
||||
// Invalidation patterns
|
||||
queryClient.invalidateQueries({ queryKey: ['post', blogId] }); // All posts for form
|
||||
queryClient.invalidateQueries({ queryKey: ['post'] }); // All posts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Service Layer Pattern
|
||||
|
||||
### File Structure
|
||||
|
||||
Create centralized API service per feature:
|
||||
|
||||
```
|
||||
features/
|
||||
my-feature/
|
||||
api/
|
||||
myFeatureApi.ts # Service layer
|
||||
```
|
||||
|
||||
### Service Pattern (from postApi.ts)
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Centralized API service for my-feature operations
|
||||
* Uses apiClient for consistent error handling
|
||||
*/
|
||||
import apiClient from '@/lib/apiClient';
|
||||
import type { MyEntity, UpdatePayload } from '../types';
|
||||
|
||||
export const myFeatureApi = {
|
||||
/**
|
||||
* Fetch a single entity
|
||||
*/
|
||||
getEntity: async (blogId: number, entityId: number): Promise<MyEntity> => {
|
||||
const { data } = await apiClient.get(
|
||||
`/blog/entities/${blogId}/${entityId}`
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all entities for a form
|
||||
*/
|
||||
getEntities: async (blogId: number, view: 'summary' | 'flat'): Promise<MyEntity[]> => {
|
||||
const { data } = await apiClient.get(
|
||||
`/blog/entities/${blogId}`,
|
||||
{ params: { view } }
|
||||
);
|
||||
return data.rows;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update entity
|
||||
*/
|
||||
updateEntity: async (
|
||||
blogId: number,
|
||||
entityId: number,
|
||||
payload: UpdatePayload
|
||||
): Promise<MyEntity> => {
|
||||
const { data } = await apiClient.put(
|
||||
`/blog/entities/${blogId}/${entityId}`,
|
||||
payload
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete entity
|
||||
*/
|
||||
deleteEntity: async (blogId: number, entityId: number): Promise<void> => {
|
||||
await apiClient.delete(`/blog/entities/${blogId}/${entityId}`);
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Export single object with methods
|
||||
- Use `apiClient` (axios instance from `@/lib/apiClient`)
|
||||
- Type-safe parameters and returns
|
||||
- JSDoc comments for each method
|
||||
- Centralized error handling (apiClient handles it)
|
||||
|
||||
---
|
||||
|
||||
## Route Format Rules (IMPORTANT)
|
||||
|
||||
### Correct Format
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Direct service path
|
||||
await apiClient.get('/blog/posts/123');
|
||||
await apiClient.post('/projects/create', data);
|
||||
await apiClient.put('/users/update/456', updates);
|
||||
await apiClient.get('/email/templates');
|
||||
|
||||
// ❌ WRONG - Do NOT add /api/ prefix
|
||||
await apiClient.get('/api/blog/posts/123'); // WRONG!
|
||||
await apiClient.post('/api/projects/create', data); // WRONG!
|
||||
```
|
||||
|
||||
**Microservice Routing:**
|
||||
- Form service: `/blog/*`
|
||||
- Projects service: `/projects/*`
|
||||
- Email service: `/email/*`
|
||||
- Users service: `/users/*`
|
||||
|
||||
**Why:** API routing is handled by proxy configuration, no `/api/` prefix needed.
|
||||
|
||||
---
|
||||
|
||||
## Mutations
|
||||
|
||||
### Basic Mutation Pattern
|
||||
|
||||
```typescript
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { myFeatureApi } from '../api/myFeatureApi';
|
||||
import { useMuiSnackbar } from '@/hooks/useMuiSnackbar';
|
||||
|
||||
export const MyComponent: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { showSuccess, showError } = useMuiSnackbar();
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (payload: UpdatePayload) =>
|
||||
myFeatureApi.updateEntity(blogId, entityId, payload),
|
||||
|
||||
onSuccess: () => {
|
||||
// Invalidate and refetch
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['entity', blogId, entityId]
|
||||
});
|
||||
showSuccess('Entity updated successfully');
|
||||
},
|
||||
|
||||
onError: (error) => {
|
||||
showError('Failed to update entity');
|
||||
console.error('Update error:', error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleUpdate = () => {
|
||||
updateMutation.mutate({ name: 'New Name' });
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleUpdate}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending ? 'Updating...' : 'Update'}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Optimistic Updates
|
||||
|
||||
```typescript
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (payload) => myFeatureApi.update(id, payload),
|
||||
|
||||
// Optimistic update
|
||||
onMutate: async (newData) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: ['entity', id] });
|
||||
|
||||
// Snapshot current value
|
||||
const previousData = queryClient.getQueryData(['entity', id]);
|
||||
|
||||
// Optimistically update
|
||||
queryClient.setQueryData(['entity', id], (old) => ({
|
||||
...old,
|
||||
...newData,
|
||||
}));
|
||||
|
||||
// Return rollback function
|
||||
return { previousData };
|
||||
},
|
||||
|
||||
// Rollback on error
|
||||
onError: (err, newData, context) => {
|
||||
queryClient.setQueryData(['entity', id], context.previousData);
|
||||
showError('Update failed');
|
||||
},
|
||||
|
||||
// Refetch after success or error
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['entity', id] });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Advanced Query Patterns
|
||||
|
||||
### Prefetching
|
||||
|
||||
```typescript
|
||||
export function usePrefetchEntity() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return (blogId: number, entityId: number) => {
|
||||
return queryClient.prefetchQuery({
|
||||
queryKey: ['entity', blogId, entityId],
|
||||
queryFn: () => myFeatureApi.getEntity(blogId, entityId),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Usage: Prefetch on hover
|
||||
<div onMouseEnter={() => prefetch(blogId, id)}>
|
||||
<Link to={`/entity/${id}`}>View</Link>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Cache Access Without Fetching
|
||||
|
||||
```typescript
|
||||
export function useEntityFromCache(blogId: number, entityId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Get from cache, don't fetch if missing
|
||||
const directCache = queryClient.getQueryData<MyEntity>(['entity', blogId, entityId]);
|
||||
|
||||
if (directCache) return directCache;
|
||||
|
||||
// Try grid cache
|
||||
const gridCache = queryClient.getQueryData<{ rows: MyEntity[] }>(['entities-v2', blogId]);
|
||||
|
||||
return gridCache?.rows.find(row => row.id === entityId);
|
||||
}
|
||||
```
|
||||
|
||||
### Dependent Queries
|
||||
|
||||
```typescript
|
||||
// Fetch user first, then user's settings
|
||||
const { data: user } = useSuspenseQuery({
|
||||
queryKey: ['user', userId],
|
||||
queryFn: () => userApi.getUser(userId),
|
||||
});
|
||||
|
||||
const { data: settings } = useSuspenseQuery({
|
||||
queryKey: ['user', userId, 'settings'],
|
||||
queryFn: () => settingsApi.getUserSettings(user.id),
|
||||
// Automatically waits for user to load due to Suspense
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Client Configuration
|
||||
|
||||
### Using apiClient
|
||||
|
||||
```typescript
|
||||
import apiClient from '@/lib/apiClient';
|
||||
|
||||
// apiClient is a configured axios instance
|
||||
// Automatically includes:
|
||||
// - Base URL configuration
|
||||
// - Cookie-based authentication
|
||||
// - Error interceptors
|
||||
// - Response transformers
|
||||
```
|
||||
|
||||
**Do NOT create new axios instances** - use apiClient for consistency.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling in Queries
|
||||
|
||||
### onError Callback
|
||||
|
||||
```typescript
|
||||
import { useMuiSnackbar } from '@/hooks/useMuiSnackbar';
|
||||
|
||||
const { showError } = useMuiSnackbar();
|
||||
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['entity', id],
|
||||
queryFn: () => myFeatureApi.getEntity(id),
|
||||
|
||||
// Handle errors
|
||||
onError: (error) => {
|
||||
showError('Failed to load entity');
|
||||
console.error('Load error:', error);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Error Boundaries
|
||||
|
||||
Combine with Error Boundaries for comprehensive error handling:
|
||||
|
||||
```typescript
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
|
||||
<ErrorBoundary
|
||||
fallback={<ErrorDisplay />}
|
||||
onError={(error) => console.error(error)}
|
||||
>
|
||||
<SuspenseLoader>
|
||||
<ComponentWithSuspenseQuery />
|
||||
</SuspenseLoader>
|
||||
</ErrorBoundary>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Examples
|
||||
|
||||
### Example 1: Simple Entity Fetch
|
||||
|
||||
```typescript
|
||||
import React from 'react';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { userApi } from '../api/userApi';
|
||||
|
||||
interface UserProfileProps {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export const UserProfile: React.FC<UserProfileProps> = ({ userId }) => {
|
||||
const { data: user } = useSuspenseQuery({
|
||||
queryKey: ['user', userId],
|
||||
queryFn: () => userApi.getUser(userId),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant='h5'>{user.name}</Typography>
|
||||
<Typography>{user.email}</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Usage with Suspense
|
||||
<SuspenseLoader>
|
||||
<UserProfile userId='123' />
|
||||
</SuspenseLoader>
|
||||
```
|
||||
|
||||
### Example 2: Cache-First Strategy
|
||||
|
||||
```typescript
|
||||
import { useSuspenseQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { postApi } from '../api/postApi';
|
||||
import type { Post } from '../types';
|
||||
|
||||
/**
|
||||
* Hook with cache-first strategy
|
||||
* Checks grid cache before API call
|
||||
*/
|
||||
export function useSuspensePost(blogId: number, postId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useSuspenseQuery<Post, Error>({
|
||||
queryKey: ['post', blogId, postId],
|
||||
queryFn: async () => {
|
||||
// 1. Check grid cache first
|
||||
const gridCache = queryClient.getQueryData<{ rows: Post[] }>([
|
||||
'posts-v2',
|
||||
blogId,
|
||||
'summary'
|
||||
]) || queryClient.getQueryData<{ rows: Post[] }>([
|
||||
'posts-v2',
|
||||
blogId,
|
||||
'flat'
|
||||
]);
|
||||
|
||||
if (gridCache?.rows) {
|
||||
const cached = gridCache.rows.find(row => row.S_ID === postId);
|
||||
if (cached) {
|
||||
return cached; // Reuse grid data
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Not in cache, fetch directly
|
||||
return postApi.getPost(blogId, postId);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Avoids duplicate API calls
|
||||
- Instant data if already loaded
|
||||
- Falls back to API if not cached
|
||||
|
||||
### Example 3: Parallel Fetching
|
||||
|
||||
```typescript
|
||||
import { useSuspenseQueries } from '@tanstack/react-query';
|
||||
|
||||
export const Dashboard: React.FC = () => {
|
||||
const [statsQuery, projectsQuery, notificationsQuery] = useSuspenseQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ['stats'],
|
||||
queryFn: () => statsApi.getStats(),
|
||||
},
|
||||
{
|
||||
queryKey: ['projects', 'active'],
|
||||
queryFn: () => projectsApi.getActiveProjects(),
|
||||
},
|
||||
{
|
||||
queryKey: ['notifications', 'unread'],
|
||||
queryFn: () => notificationsApi.getUnread(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<StatsCard data={statsQuery.data} />
|
||||
<ProjectsList projects={projectsQuery.data} />
|
||||
<Notifications items={notificationsQuery.data} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mutations with Cache Invalidation
|
||||
|
||||
### Update Mutation
|
||||
|
||||
```typescript
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { postApi } from '../api/postApi';
|
||||
import { useMuiSnackbar } from '@/hooks/useMuiSnackbar';
|
||||
|
||||
export const useUpdatePost = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { showSuccess, showError } = useMuiSnackbar();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ blogId, postId, data }: UpdateParams) =>
|
||||
postApi.updatePost(blogId, postId, data),
|
||||
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate specific post
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['post', variables.blogId, variables.postId]
|
||||
});
|
||||
|
||||
// Invalidate list to refresh grid
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['posts-v2', variables.blogId]
|
||||
});
|
||||
|
||||
showSuccess('Post updated');
|
||||
},
|
||||
|
||||
onError: (error) => {
|
||||
showError('Failed to update post');
|
||||
console.error('Update error:', error);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Usage
|
||||
const updatePost = useUpdatePost();
|
||||
|
||||
const handleSave = () => {
|
||||
updatePost.mutate({
|
||||
blogId: 123,
|
||||
postId: 456,
|
||||
data: { responses: { '101': 'value' } }
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### Delete Mutation
|
||||
|
||||
```typescript
|
||||
export const useDeletePost = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { showSuccess, showError } = useMuiSnackbar();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ blogId, postId }: DeleteParams) =>
|
||||
postApi.deletePost(blogId, postId),
|
||||
|
||||
onSuccess: (data, variables) => {
|
||||
// Remove from cache manually (optimistic)
|
||||
queryClient.setQueryData<{ rows: Post[] }>(
|
||||
['posts-v2', variables.blogId],
|
||||
(old) => ({
|
||||
...old,
|
||||
rows: old?.rows.filter(row => row.S_ID !== variables.postId) || []
|
||||
})
|
||||
);
|
||||
|
||||
showSuccess('Post deleted');
|
||||
},
|
||||
|
||||
onError: (error, variables) => {
|
||||
// Rollback - refetch to get accurate state
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['posts-v2', variables.blogId]
|
||||
});
|
||||
showError('Failed to delete post');
|
||||
},
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Query Configuration Best Practices
|
||||
|
||||
### Default Configuration
|
||||
|
||||
```typescript
|
||||
// In QueryClientProvider setup
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 10, // 10 minutes (was cacheTime)
|
||||
refetchOnWindowFocus: false, // Don't refetch on focus
|
||||
refetchOnMount: false, // Don't refetch on mount if fresh
|
||||
retry: 1, // Retry failed queries once
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Per-Query Overrides
|
||||
|
||||
```typescript
|
||||
// Frequently changing data - shorter staleTime
|
||||
useSuspenseQuery({
|
||||
queryKey: ['notifications', 'unread'],
|
||||
queryFn: () => notificationApi.getUnread(),
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
});
|
||||
|
||||
// Rarely changing data - longer staleTime
|
||||
useSuspenseQuery({
|
||||
queryKey: ['form', blogId, 'structure'],
|
||||
queryFn: () => formApi.getStructure(blogId),
|
||||
staleTime: 30 * 60 * 1000, // 30 minutes
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Modern Data Fetching Recipe:**
|
||||
|
||||
1. **Create API Service**: `features/X/api/XApi.ts` using apiClient
|
||||
2. **Use useSuspenseQuery**: In components wrapped by SuspenseLoader
|
||||
3. **Cache-First**: Check grid cache before API call
|
||||
4. **Query Keys**: Consistent naming ['entity', id]
|
||||
5. **Route Format**: `/blog/route` NOT `/api/blog/route`
|
||||
6. **Mutations**: invalidateQueries after success
|
||||
7. **Error Handling**: onError + useMuiSnackbar
|
||||
8. **Type Safety**: Type all parameters and returns
|
||||
|
||||
**See Also:**
|
||||
- [component-patterns.md](component-patterns.md) - Suspense integration
|
||||
- [loading-and-error-states.md](loading-and-error-states.md) - SuspenseLoader usage
|
||||
- [complete-examples.md](complete-examples.md) - Full working examples
|
||||
@@ -0,0 +1,502 @@
|
||||
# File Organization
|
||||
|
||||
Proper file and directory structure for maintainable, scalable frontend code in the the application.
|
||||
|
||||
---
|
||||
|
||||
## features/ vs components/ Distinction
|
||||
|
||||
### features/ Directory
|
||||
|
||||
**Purpose**: Domain-specific features with their own logic, API, and components
|
||||
|
||||
**When to use:**
|
||||
- Feature has multiple related components
|
||||
- Feature has its own API endpoints
|
||||
- Feature has domain-specific logic
|
||||
- Feature has custom hooks/utilities
|
||||
|
||||
**Examples:**
|
||||
- `features/posts/` - Project catalog/post management
|
||||
- `features/blogs/` - Blog builder and rendering
|
||||
- `features/auth/` - Authentication flows
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
features/
|
||||
my-feature/
|
||||
api/
|
||||
myFeatureApi.ts # API service layer
|
||||
components/
|
||||
MyFeatureMain.tsx # Main component
|
||||
SubComponents/ # Related components
|
||||
hooks/
|
||||
useMyFeature.ts # Custom hooks
|
||||
useSuspenseMyFeature.ts # Suspense hooks
|
||||
helpers/
|
||||
myFeatureHelpers.ts # Utility functions
|
||||
types/
|
||||
index.ts # TypeScript types
|
||||
index.ts # Public exports
|
||||
```
|
||||
|
||||
### components/ Directory
|
||||
|
||||
**Purpose**: Truly reusable components used across multiple features
|
||||
|
||||
**When to use:**
|
||||
- Component is used in 3+ places
|
||||
- Component is generic (no feature-specific logic)
|
||||
- Component is a UI primitive or pattern
|
||||
|
||||
**Examples:**
|
||||
- `components/SuspenseLoader/` - Loading wrapper
|
||||
- `components/CustomAppBar/` - Application header
|
||||
- `components/ErrorBoundary/` - Error handling
|
||||
- `components/LoadingOverlay/` - Loading overlay
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
components/
|
||||
SuspenseLoader/
|
||||
SuspenseLoader.tsx
|
||||
SuspenseLoader.test.tsx
|
||||
CustomAppBar/
|
||||
CustomAppBar.tsx
|
||||
CustomAppBar.test.tsx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Feature Directory Structure (Detailed)
|
||||
|
||||
### Complete Feature Example
|
||||
|
||||
Based on `features/posts/` structure:
|
||||
|
||||
```
|
||||
features/
|
||||
posts/
|
||||
api/
|
||||
postApi.ts # API service layer (GET, POST, PUT, DELETE)
|
||||
|
||||
components/
|
||||
PostTable.tsx # Main container component
|
||||
grids/
|
||||
PostDataGrid/
|
||||
PostDataGrid.tsx
|
||||
drawers/
|
||||
ProjectPostDrawer/
|
||||
ProjectPostDrawer.tsx
|
||||
cells/
|
||||
editors/
|
||||
TextEditCell.tsx
|
||||
renderers/
|
||||
DateCell.tsx
|
||||
toolbar/
|
||||
CustomToolbar.tsx
|
||||
|
||||
hooks/
|
||||
usePostQueries.ts # Regular queries
|
||||
useSuspensePost.ts # Suspense queries
|
||||
usePostMutations.ts # Mutations
|
||||
useGridLayout.ts # Feature-specific hooks
|
||||
|
||||
helpers/
|
||||
postHelpers.ts # Utility functions
|
||||
validation.ts # Validation logic
|
||||
|
||||
types/
|
||||
index.ts # TypeScript types/interfaces
|
||||
|
||||
queries/
|
||||
postQueries.ts # Query key factories (optional)
|
||||
|
||||
context/
|
||||
PostContext.tsx # React context (if needed)
|
||||
|
||||
index.ts # Public API exports
|
||||
```
|
||||
|
||||
### Subdirectory Guidelines
|
||||
|
||||
#### api/ Directory
|
||||
|
||||
**Purpose**: Centralized API calls for the feature
|
||||
|
||||
**Files:**
|
||||
- `{feature}Api.ts` - Main API service
|
||||
|
||||
**Pattern:**
|
||||
```typescript
|
||||
// features/my-feature/api/myFeatureApi.ts
|
||||
import apiClient from '@/lib/apiClient';
|
||||
|
||||
export const myFeatureApi = {
|
||||
getItem: async (id: number) => {
|
||||
const { data } = await apiClient.get(`/blog/items/${id}`);
|
||||
return data;
|
||||
},
|
||||
createItem: async (payload) => {
|
||||
const { data } = await apiClient.post('/blog/items', payload);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
#### components/ Directory
|
||||
|
||||
**Purpose**: Feature-specific components
|
||||
|
||||
**Organization:**
|
||||
- Flat structure if <5 components
|
||||
- Subdirectories by responsibility if >5 components
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
components/
|
||||
MyFeatureMain.tsx # Main component
|
||||
MyFeatureHeader.tsx # Supporting components
|
||||
MyFeatureFooter.tsx
|
||||
|
||||
# OR with subdirectories:
|
||||
containers/
|
||||
MyFeatureContainer.tsx
|
||||
presentational/
|
||||
MyFeatureDisplay.tsx
|
||||
blogs/
|
||||
MyFeatureBlog.tsx
|
||||
```
|
||||
|
||||
#### hooks/ Directory
|
||||
|
||||
**Purpose**: Custom hooks for the feature
|
||||
|
||||
**Naming:**
|
||||
- `use` prefix (camelCase)
|
||||
- Descriptive of what they do
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
hooks/
|
||||
useMyFeature.ts # Main hook
|
||||
useSuspenseMyFeature.ts # Suspense version
|
||||
useMyFeatureMutations.ts # Mutations
|
||||
useMyFeatureFilters.ts # Filters/search
|
||||
```
|
||||
|
||||
#### helpers/ Directory
|
||||
|
||||
**Purpose**: Utility functions specific to the feature
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
helpers/
|
||||
myFeatureHelpers.ts # General utilities
|
||||
validation.ts # Validation logic
|
||||
transblogers.ts # Data transblogations
|
||||
constants.ts # Constants
|
||||
```
|
||||
|
||||
#### types/ Directory
|
||||
|
||||
**Purpose**: TypeScript types and interfaces
|
||||
|
||||
**Files:**
|
||||
```
|
||||
types/
|
||||
index.ts # Main types, exported
|
||||
internal.ts # Internal types (not exported)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Import Aliases (Vite Configuration)
|
||||
|
||||
### Available Aliases
|
||||
|
||||
From `vite.config.ts` lines 180-185:
|
||||
|
||||
| Alias | Resolves To | Use For |
|
||||
|-------|-------------|---------|
|
||||
| `@/` | `src/` | Absolute imports from src root |
|
||||
| `~types` | `src/types` | Shared TypeScript types |
|
||||
| `~components` | `src/components` | Reusable components |
|
||||
| `~features` | `src/features` | Feature imports |
|
||||
|
||||
### Usage Examples
|
||||
|
||||
```typescript
|
||||
// ✅ PREFERRED - Use aliases for absolute imports
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
import { SuspenseLoader } from '~components/SuspenseLoader';
|
||||
import { postApi } from '~features/posts/api/postApi';
|
||||
import type { User } from '~types/user';
|
||||
|
||||
// ❌ AVOID - Relative paths from deep nesting
|
||||
import { apiClient } from '../../../lib/apiClient';
|
||||
import { SuspenseLoader } from '../../../components/SuspenseLoader';
|
||||
```
|
||||
|
||||
### When to Use Which Alias
|
||||
|
||||
**@/ (General)**:
|
||||
- Lib utilities: `@/lib/apiClient`
|
||||
- Hooks: `@/hooks/useAuth`
|
||||
- Config: `@/config/theme`
|
||||
- Shared services: `@/services/authService`
|
||||
|
||||
**~types (Type Imports)**:
|
||||
```typescript
|
||||
import type { Post } from '~types/post';
|
||||
import type { User, UserRole } from '~types/user';
|
||||
```
|
||||
|
||||
**~components (Reusable Components)**:
|
||||
```typescript
|
||||
import { SuspenseLoader } from '~components/SuspenseLoader';
|
||||
import { CustomAppBar } from '~components/CustomAppBar';
|
||||
import { ErrorBoundary } from '~components/ErrorBoundary';
|
||||
```
|
||||
|
||||
**~features (Feature Imports)**:
|
||||
```typescript
|
||||
import { postApi } from '~features/posts/api/postApi';
|
||||
import { useAuth } from '~features/auth/hooks/useAuth';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Naming Conventions
|
||||
|
||||
### Components
|
||||
|
||||
**Pattern**: PascalCase with `.tsx` extension
|
||||
|
||||
```
|
||||
MyComponent.tsx
|
||||
PostDataGrid.tsx
|
||||
CustomAppBar.tsx
|
||||
```
|
||||
|
||||
**Avoid:**
|
||||
- camelCase: `myComponent.tsx` ❌
|
||||
- kebab-case: `my-component.tsx` ❌
|
||||
- All caps: `MYCOMPONENT.tsx` ❌
|
||||
|
||||
### Hooks
|
||||
|
||||
**Pattern**: camelCase with `use` prefix, `.ts` extension
|
||||
|
||||
```
|
||||
useMyFeature.ts
|
||||
useSuspensePost.ts
|
||||
useAuth.ts
|
||||
useGridLayout.ts
|
||||
```
|
||||
|
||||
### API Services
|
||||
|
||||
**Pattern**: camelCase with `Api` suffix, `.ts` extension
|
||||
|
||||
```
|
||||
myFeatureApi.ts
|
||||
postApi.ts
|
||||
userApi.ts
|
||||
```
|
||||
|
||||
### Helpers/Utilities
|
||||
|
||||
**Pattern**: camelCase with descriptive name, `.ts` extension
|
||||
|
||||
```
|
||||
myFeatureHelpers.ts
|
||||
validation.ts
|
||||
transblogers.ts
|
||||
constants.ts
|
||||
```
|
||||
|
||||
### Types
|
||||
|
||||
**Pattern**: camelCase, `index.ts` or descriptive name
|
||||
|
||||
```
|
||||
types/index.ts
|
||||
types/post.ts
|
||||
types/user.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## When to Create a New Feature
|
||||
|
||||
### Create New Feature When:
|
||||
|
||||
- Multiple related components (>3)
|
||||
- Has own API endpoints
|
||||
- Domain-specific logic
|
||||
- Will grow over time
|
||||
- Reused across multiple routes
|
||||
|
||||
**Example:** `features/posts/`
|
||||
- 20+ components
|
||||
- Own API service
|
||||
- Complex state management
|
||||
- Used in multiple routes
|
||||
|
||||
### Add to Existing Feature When:
|
||||
|
||||
- Related to existing feature
|
||||
- Shares same API
|
||||
- Logically grouped
|
||||
- Extends existing functionality
|
||||
|
||||
**Example:** Adding export dialog to posts feature
|
||||
|
||||
### Create Reusable Component When:
|
||||
|
||||
- Used across 3+ features
|
||||
- Generic, no domain logic
|
||||
- Pure presentation
|
||||
- Shared pattern
|
||||
|
||||
**Example:** `components/SuspenseLoader/`
|
||||
|
||||
---
|
||||
|
||||
## Import Organization
|
||||
|
||||
### Import Order (Recommended)
|
||||
|
||||
```typescript
|
||||
// 1. React and React-related
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { lazy } from 'react';
|
||||
|
||||
// 2. Third-party libraries (alphabetical)
|
||||
import { Box, Paper, Button, Grid } from '@mui/material';
|
||||
import type { SxProps, Theme } from '@mui/material';
|
||||
import { useSuspenseQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
// 3. Alias imports (@ first, then ~)
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useMuiSnackbar } from '@/hooks/useMuiSnackbar';
|
||||
import { SuspenseLoader } from '~components/SuspenseLoader';
|
||||
import { postApi } from '~features/posts/api/postApi';
|
||||
|
||||
// 4. Type imports (grouped)
|
||||
import type { Post } from '~types/post';
|
||||
import type { User } from '~types/user';
|
||||
|
||||
// 5. Relative imports (same feature)
|
||||
import { MySubComponent } from './MySubComponent';
|
||||
import { useMyFeature } from '../hooks/useMyFeature';
|
||||
import { myFeatureHelpers } from '../helpers/myFeatureHelpers';
|
||||
```
|
||||
|
||||
**Use single quotes** for all imports (project standard)
|
||||
|
||||
---
|
||||
|
||||
## Public API Pattern
|
||||
|
||||
### feature/index.ts
|
||||
|
||||
Export public API from feature for clean imports:
|
||||
|
||||
```typescript
|
||||
// features/my-feature/index.ts
|
||||
|
||||
// Export main components
|
||||
export { MyFeatureMain } from './components/MyFeatureMain';
|
||||
export { MyFeatureHeader } from './components/MyFeatureHeader';
|
||||
|
||||
// Export hooks
|
||||
export { useMyFeature } from './hooks/useMyFeature';
|
||||
export { useSuspenseMyFeature } from './hooks/useSuspenseMyFeature';
|
||||
|
||||
// Export API
|
||||
export { myFeatureApi } from './api/myFeatureApi';
|
||||
|
||||
// Export types
|
||||
export type { MyFeatureData, MyFeatureConfig } from './types';
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
// ✅ Clean import from feature index
|
||||
import { MyFeatureMain, useMyFeature } from '~features/my-feature';
|
||||
|
||||
// ❌ Avoid deep imports (but OK if needed)
|
||||
import { MyFeatureMain } from '~features/my-feature/components/MyFeatureMain';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure Visualization
|
||||
|
||||
```
|
||||
src/
|
||||
├── features/ # Domain-specific features
|
||||
│ ├── posts/
|
||||
│ │ ├── api/
|
||||
│ │ ├── components/
|
||||
│ │ ├── hooks/
|
||||
│ │ ├── helpers/
|
||||
│ │ ├── types/
|
||||
│ │ └── index.ts
|
||||
│ ├── blogs/
|
||||
│ └── auth/
|
||||
│
|
||||
├── components/ # Reusable components
|
||||
│ ├── SuspenseLoader/
|
||||
│ ├── CustomAppBar/
|
||||
│ ├── ErrorBoundary/
|
||||
│ └── LoadingOverlay/
|
||||
│
|
||||
├── routes/ # TanStack Router routes
|
||||
│ ├── __root.tsx
|
||||
│ ├── index.tsx
|
||||
│ ├── project-catalog/
|
||||
│ │ ├── index.tsx
|
||||
│ │ └── create/
|
||||
│ └── blogs/
|
||||
│
|
||||
├── hooks/ # Shared hooks
|
||||
│ ├── useAuth.ts
|
||||
│ ├── useMuiSnackbar.ts
|
||||
│ └── useDebounce.ts
|
||||
│
|
||||
├── lib/ # Shared utilities
|
||||
│ ├── apiClient.ts
|
||||
│ └── utils.ts
|
||||
│
|
||||
├── types/ # Shared TypeScript types
|
||||
│ ├── user.ts
|
||||
│ ├── post.ts
|
||||
│ └── common.ts
|
||||
│
|
||||
├── config/ # Configuration
|
||||
│ └── theme.ts
|
||||
│
|
||||
└── App.tsx # Root component
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Key Principles:**
|
||||
1. **features/** for domain-specific code
|
||||
2. **components/** for truly reusable UI
|
||||
3. Use subdirectories: api/, components/, hooks/, helpers/, types/
|
||||
4. Import aliases for clean imports (@/, ~types, ~components, ~features)
|
||||
5. Consistent naming: PascalCase components, camelCase utilities
|
||||
6. Export public API from feature index.ts
|
||||
|
||||
**See Also:**
|
||||
- [component-patterns.md](component-patterns.md) - Component structure
|
||||
- [data-fetching.md](data-fetching.md) - API service patterns
|
||||
- [complete-examples.md](complete-examples.md) - Full feature example
|
||||
@@ -0,0 +1,501 @@
|
||||
# Loading & Error States
|
||||
|
||||
**CRITICAL**: Proper loading and error state handling prevents layout shift and provides better user experience.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ CRITICAL RULE: Never Use Early Returns
|
||||
|
||||
### The Problem
|
||||
|
||||
```typescript
|
||||
// ❌ NEVER DO THIS - Early return with loading spinner
|
||||
const Component = () => {
|
||||
const { data, isLoading } = useQuery();
|
||||
|
||||
// WRONG: This causes layout shift and poor UX
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
return <Content data={data} />;
|
||||
};
|
||||
```
|
||||
|
||||
**Why this is bad:**
|
||||
1. **Layout Shift**: Content position jumps when loading completes
|
||||
2. **CLS (Cumulative Layout Shift)**: Poor Core Web Vital score
|
||||
3. **Jarring UX**: Page structure changes suddenly
|
||||
4. **Lost Scroll Position**: User loses place on page
|
||||
|
||||
### The Solutions
|
||||
|
||||
**Option 1: SuspenseLoader (PREFERRED for new components)**
|
||||
|
||||
```typescript
|
||||
import { SuspenseLoader } from '~components/SuspenseLoader';
|
||||
|
||||
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));
|
||||
|
||||
export const MyComponent: React.FC = () => {
|
||||
return (
|
||||
<SuspenseLoader>
|
||||
<HeavyComponent />
|
||||
</SuspenseLoader>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**Option 2: LoadingOverlay (for legacy useQuery patterns)**
|
||||
|
||||
```typescript
|
||||
import { LoadingOverlay } from '~components/LoadingOverlay';
|
||||
|
||||
export const MyComponent: React.FC = () => {
|
||||
const { data, isLoading } = useQuery({ ... });
|
||||
|
||||
return (
|
||||
<LoadingOverlay loading={isLoading}>
|
||||
<Content data={data} />
|
||||
</LoadingOverlay>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SuspenseLoader Component
|
||||
|
||||
### What It Does
|
||||
|
||||
- Shows loading indicator while lazy components load
|
||||
- Smooth fade-in animation
|
||||
- Prevents layout shift
|
||||
- Consistent loading experience across app
|
||||
|
||||
### Import
|
||||
|
||||
```typescript
|
||||
import { SuspenseLoader } from '~components/SuspenseLoader';
|
||||
// Or
|
||||
import { SuspenseLoader } from '@/components/SuspenseLoader';
|
||||
```
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
<SuspenseLoader>
|
||||
<LazyLoadedComponent />
|
||||
</SuspenseLoader>
|
||||
```
|
||||
|
||||
### With useSuspenseQuery
|
||||
|
||||
```typescript
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { SuspenseLoader } from '~components/SuspenseLoader';
|
||||
|
||||
const Inner: React.FC = () => {
|
||||
// No isLoading needed!
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['data'],
|
||||
queryFn: () => api.getData(),
|
||||
});
|
||||
|
||||
return <Display data={data} />;
|
||||
};
|
||||
|
||||
// Outer component wraps in Suspense
|
||||
export const Outer: React.FC = () => {
|
||||
return (
|
||||
<SuspenseLoader>
|
||||
<Inner />
|
||||
</SuspenseLoader>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Multiple Suspense Boundaries
|
||||
|
||||
**Pattern**: Separate loading for independent sections
|
||||
|
||||
```typescript
|
||||
export const Dashboard: React.FC = () => {
|
||||
return (
|
||||
<Box>
|
||||
<SuspenseLoader>
|
||||
<Header />
|
||||
</SuspenseLoader>
|
||||
|
||||
<SuspenseLoader>
|
||||
<MainContent />
|
||||
</SuspenseLoader>
|
||||
|
||||
<SuspenseLoader>
|
||||
<Sidebar />
|
||||
</SuspenseLoader>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Each section loads independently
|
||||
- User sees partial content sooner
|
||||
- Better perceived performance
|
||||
|
||||
### Nested Suspense
|
||||
|
||||
```typescript
|
||||
export const ParentComponent: React.FC = () => {
|
||||
return (
|
||||
<SuspenseLoader>
|
||||
{/* Parent suspends while loading */}
|
||||
<ParentContent>
|
||||
<SuspenseLoader>
|
||||
{/* Nested suspense for child */}
|
||||
<ChildComponent />
|
||||
</SuspenseLoader>
|
||||
</ParentContent>
|
||||
</SuspenseLoader>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## LoadingOverlay Component
|
||||
|
||||
### When to Use
|
||||
|
||||
- Legacy components with `useQuery` (not refactored to Suspense yet)
|
||||
- Overlay loading state needed
|
||||
- Can't use Suspense boundaries
|
||||
|
||||
### Usage
|
||||
|
||||
```typescript
|
||||
import { LoadingOverlay } from '~components/LoadingOverlay';
|
||||
|
||||
export const MyComponent: React.FC = () => {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['data'],
|
||||
queryFn: () => api.getData(),
|
||||
});
|
||||
|
||||
return (
|
||||
<LoadingOverlay loading={isLoading}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
{data && <Content data={data} />}
|
||||
</Box>
|
||||
</LoadingOverlay>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Shows semi-transparent overlay with spinner
|
||||
- Content area reserved (no layout shift)
|
||||
- Prevents interaction while loading
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### useMuiSnackbar Hook (REQUIRED)
|
||||
|
||||
**NEVER use react-toastify** - Project standard is MUI Snackbar
|
||||
|
||||
```typescript
|
||||
import { useMuiSnackbar } from '@/hooks/useMuiSnackbar';
|
||||
|
||||
export const MyComponent: React.FC = () => {
|
||||
const { showSuccess, showError, showInfo, showWarning } = useMuiSnackbar();
|
||||
|
||||
const handleAction = async () => {
|
||||
try {
|
||||
await api.doSomething();
|
||||
showSuccess('Operation completed successfully');
|
||||
} catch (error) {
|
||||
showError('Operation failed');
|
||||
}
|
||||
};
|
||||
|
||||
return <Button onClick={handleAction}>Do Action</Button>;
|
||||
};
|
||||
```
|
||||
|
||||
**Available Methods:**
|
||||
- `showSuccess(message)` - Green success message
|
||||
- `showError(message)` - Red error message
|
||||
- `showWarning(message)` - Orange warning message
|
||||
- `showInfo(message)` - Blue info message
|
||||
|
||||
### TanStack Query Error Callbacks
|
||||
|
||||
```typescript
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { useMuiSnackbar } from '@/hooks/useMuiSnackbar';
|
||||
|
||||
export const MyComponent: React.FC = () => {
|
||||
const { showError } = useMuiSnackbar();
|
||||
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['data'],
|
||||
queryFn: () => api.getData(),
|
||||
|
||||
// Handle errors
|
||||
onError: (error) => {
|
||||
showError('Failed to load data');
|
||||
console.error('Query error:', error);
|
||||
},
|
||||
});
|
||||
|
||||
return <Content data={data} />;
|
||||
};
|
||||
```
|
||||
|
||||
### Error Boundaries
|
||||
|
||||
```typescript
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
|
||||
function ErrorFallback({ error, resetErrorBoundary }) {
|
||||
return (
|
||||
<Box sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography variant='h5' color='error'>
|
||||
Something went wrong
|
||||
</Typography>
|
||||
<Typography>{error.message}</Typography>
|
||||
<Button onClick={resetErrorBoundary}>Try Again</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export const MyPage: React.FC = () => {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
FallbackComponent={ErrorFallback}
|
||||
onError={(error) => console.error('Boundary caught:', error)}
|
||||
>
|
||||
<SuspenseLoader>
|
||||
<ComponentThatMightError />
|
||||
</SuspenseLoader>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Examples
|
||||
|
||||
### Example 1: Modern Component with Suspense
|
||||
|
||||
```typescript
|
||||
import React from 'react';
|
||||
import { Box, Paper } from '@mui/material';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { SuspenseLoader } from '~components/SuspenseLoader';
|
||||
import { myFeatureApi } from '../api/myFeatureApi';
|
||||
|
||||
// Inner component uses useSuspenseQuery
|
||||
const InnerComponent: React.FC<{ id: number }> = ({ id }) => {
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['entity', id],
|
||||
queryFn: () => myFeatureApi.getEntity(id),
|
||||
});
|
||||
|
||||
// data is always defined - no isLoading needed!
|
||||
return (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<h2>{data.title}</h2>
|
||||
<p>{data.description}</p>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
// Outer component provides Suspense boundary
|
||||
export const OuterComponent: React.FC<{ id: number }> = ({ id }) => {
|
||||
return (
|
||||
<Box>
|
||||
<SuspenseLoader>
|
||||
<InnerComponent id={id} />
|
||||
</SuspenseLoader>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default OuterComponent;
|
||||
```
|
||||
|
||||
### Example 2: Legacy Pattern with LoadingOverlay
|
||||
|
||||
```typescript
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { LoadingOverlay } from '~components/LoadingOverlay';
|
||||
import { myFeatureApi } from '../api/myFeatureApi';
|
||||
|
||||
export const LegacyComponent: React.FC<{ id: number }> = ({ id }) => {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['entity', id],
|
||||
queryFn: () => myFeatureApi.getEntity(id),
|
||||
});
|
||||
|
||||
return (
|
||||
<LoadingOverlay loading={isLoading}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
{error && <ErrorDisplay error={error} />}
|
||||
{data && <Content data={data} />}
|
||||
</Box>
|
||||
</LoadingOverlay>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Example 3: Error Handling with Snackbar
|
||||
|
||||
```typescript
|
||||
import React from 'react';
|
||||
import { useSuspenseQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@mui/material';
|
||||
import { useMuiSnackbar } from '@/hooks/useMuiSnackbar';
|
||||
import { myFeatureApi } from '../api/myFeatureApi';
|
||||
|
||||
export const EntityEditor: React.FC<{ id: number }> = ({ id }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { showSuccess, showError } = useMuiSnackbar();
|
||||
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['entity', id],
|
||||
queryFn: () => myFeatureApi.getEntity(id),
|
||||
onError: () => {
|
||||
showError('Failed to load entity');
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (updates) => myFeatureApi.update(id, updates),
|
||||
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['entity', id] });
|
||||
showSuccess('Entity updated successfully');
|
||||
},
|
||||
|
||||
onError: () => {
|
||||
showError('Failed to update entity');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Button onClick={() => updateMutation.mutate({ name: 'New' })}>
|
||||
Update
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Loading State Anti-Patterns
|
||||
|
||||
### ❌ What NOT to Do
|
||||
|
||||
```typescript
|
||||
// ❌ NEVER - Early return
|
||||
if (isLoading) {
|
||||
return <CircularProgress />;
|
||||
}
|
||||
|
||||
// ❌ NEVER - Conditional rendering
|
||||
{isLoading ? <Spinner /> : <Content />}
|
||||
|
||||
// ❌ NEVER - Layout changes
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{ height: 100 }}>
|
||||
<Spinner />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box sx={{ height: 500 }}> // Different height!
|
||||
<Content />
|
||||
</Box>
|
||||
);
|
||||
```
|
||||
|
||||
### ✅ What TO Do
|
||||
|
||||
```typescript
|
||||
// ✅ BEST - useSuspenseQuery + SuspenseLoader
|
||||
<SuspenseLoader>
|
||||
<ComponentWithSuspenseQuery />
|
||||
</SuspenseLoader>
|
||||
|
||||
// ✅ ACCEPTABLE - LoadingOverlay
|
||||
<LoadingOverlay loading={isLoading}>
|
||||
<Content />
|
||||
</LoadingOverlay>
|
||||
|
||||
// ✅ OK - Inline skeleton with same layout
|
||||
<Box sx={{ height: 500 }}>
|
||||
{isLoading ? <Skeleton variant='rectangular' height='100%' /> : <Content />}
|
||||
</Box>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Skeleton Loading (Alternative)
|
||||
|
||||
### MUI Skeleton Component
|
||||
|
||||
```typescript
|
||||
import { Skeleton, Box } from '@mui/material';
|
||||
|
||||
export const MyComponent: React.FC = () => {
|
||||
const { data, isLoading } = useQuery({ ... });
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Skeleton variant='text' width={200} height={40} />
|
||||
<Skeleton variant='rectangular' width='100%' height={200} />
|
||||
<Skeleton variant='text' width='100%' />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Typography variant='h5'>{data.title}</Typography>
|
||||
<img src={data.image} />
|
||||
<Typography>{data.description}</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**Key**: Skeleton must have **same layout** as actual content (no shift)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Loading States:**
|
||||
- ✅ **PREFERRED**: SuspenseLoader + useSuspenseQuery (modern pattern)
|
||||
- ✅ **ACCEPTABLE**: LoadingOverlay (legacy pattern)
|
||||
- ✅ **OK**: Skeleton with same layout
|
||||
- ❌ **NEVER**: Early returns or conditional layout
|
||||
|
||||
**Error Handling:**
|
||||
- ✅ **ALWAYS**: useMuiSnackbar for user feedback
|
||||
- ❌ **NEVER**: react-toastify
|
||||
- ✅ Use onError callbacks in queries/mutations
|
||||
- ✅ Error boundaries for component-level errors
|
||||
|
||||
**See Also:**
|
||||
- [component-patterns.md](component-patterns.md) - Suspense integration
|
||||
- [data-fetching.md](data-fetching.md) - useSuspenseQuery details
|
||||
406
.opencode/skills/frontend-development/resources/performance.md
Normal file
406
.opencode/skills/frontend-development/resources/performance.md
Normal file
@@ -0,0 +1,406 @@
|
||||
# Performance Optimization
|
||||
|
||||
Patterns for optimizing React component performance, preventing unnecessary re-renders, and avoiding memory leaks.
|
||||
|
||||
---
|
||||
|
||||
## Memoization Patterns
|
||||
|
||||
### useMemo for Expensive Computations
|
||||
|
||||
```typescript
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export const DataDisplay: React.FC<{ items: Item[], searchTerm: string }> = ({
|
||||
items,
|
||||
searchTerm,
|
||||
}) => {
|
||||
// ❌ AVOID - Runs on every render
|
||||
const filteredItems = items
|
||||
.filter(item => item.name.includes(searchTerm))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// ✅ CORRECT - Memoized, only recalculates when dependencies change
|
||||
const filteredItems = useMemo(() => {
|
||||
return items
|
||||
.filter(item => item.name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [items, searchTerm]);
|
||||
|
||||
return <List items={filteredItems} />;
|
||||
};
|
||||
```
|
||||
|
||||
**When to use useMemo:**
|
||||
- Filtering/sorting large arrays
|
||||
- Complex calculations
|
||||
- Transforming data structures
|
||||
- Expensive computations (loops, recursion)
|
||||
|
||||
**When NOT to use useMemo:**
|
||||
- Simple string concatenation
|
||||
- Basic arithmetic
|
||||
- Premature optimization (profile first!)
|
||||
|
||||
---
|
||||
|
||||
## useCallback for Event Handlers
|
||||
|
||||
### The Problem
|
||||
|
||||
```typescript
|
||||
// ❌ AVOID - Creates new function on every render
|
||||
export const Parent: React.FC = () => {
|
||||
const handleClick = (id: string) => {
|
||||
console.log('Clicked:', id);
|
||||
};
|
||||
|
||||
// Child re-renders every time Parent renders
|
||||
// because handleClick is a new function reference each time
|
||||
return <Child onClick={handleClick} />;
|
||||
};
|
||||
```
|
||||
|
||||
### The Solution
|
||||
|
||||
```typescript
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export const Parent: React.FC = () => {
|
||||
// ✅ CORRECT - Stable function reference
|
||||
const handleClick = useCallback((id: string) => {
|
||||
console.log('Clicked:', id);
|
||||
}, []); // Empty deps = function never changes
|
||||
|
||||
// Child only re-renders when props actually change
|
||||
return <Child onClick={handleClick} />;
|
||||
};
|
||||
```
|
||||
|
||||
**When to use useCallback:**
|
||||
- Functions passed as props to children
|
||||
- Functions used as dependencies in useEffect
|
||||
- Functions passed to memoized components
|
||||
- Event handlers in lists
|
||||
|
||||
**When NOT to use useCallback:**
|
||||
- Event handlers not passed to children
|
||||
- Simple inline handlers: `onClick={() => doSomething()}`
|
||||
|
||||
---
|
||||
|
||||
## React.memo for Component Memoization
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import React from 'react';
|
||||
|
||||
interface ExpensiveComponentProps {
|
||||
data: ComplexData;
|
||||
onAction: () => void;
|
||||
}
|
||||
|
||||
// ✅ Wrap expensive components in React.memo
|
||||
export const ExpensiveComponent = React.memo<ExpensiveComponentProps>(
|
||||
function ExpensiveComponent({ data, onAction }) {
|
||||
// Complex rendering logic
|
||||
return <ComplexVisualization data={data} />;
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
**When to use React.memo:**
|
||||
- Component renders frequently
|
||||
- Component has expensive rendering
|
||||
- Props don't change often
|
||||
- Component is a list item
|
||||
- DataGrid cells/renderers
|
||||
|
||||
**When NOT to use React.memo:**
|
||||
- Props change frequently anyway
|
||||
- Rendering is already fast
|
||||
- Premature optimization
|
||||
|
||||
---
|
||||
|
||||
## Debounced Search
|
||||
|
||||
### Using use-debounce Hook
|
||||
|
||||
```typescript
|
||||
import { useState } from 'react';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
|
||||
export const SearchComponent: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Debounce for 300ms
|
||||
const [debouncedSearchTerm] = useDebounce(searchTerm, 300);
|
||||
|
||||
// Query uses debounced value
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['search', debouncedSearchTerm],
|
||||
queryFn: () => api.search(debouncedSearchTerm),
|
||||
enabled: debouncedSearchTerm.length > 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<input
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder='Search...'
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**Optimal Debounce Timing:**
|
||||
- **300-500ms**: Search/filtering
|
||||
- **1000ms**: Auto-save
|
||||
- **100-200ms**: Real-time validation
|
||||
|
||||
---
|
||||
|
||||
## Memory Leak Prevention
|
||||
|
||||
### Cleanup Timeouts/Intervals
|
||||
|
||||
```typescript
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const MyComponent: React.FC = () => {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// ✅ CORRECT - Cleanup interval
|
||||
const intervalId = setInterval(() => {
|
||||
setCount(c => c + 1);
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId); // Cleanup!
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// ✅ CORRECT - Cleanup timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
console.log('Delayed action');
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId); // Cleanup!
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div>{count}</div>;
|
||||
};
|
||||
```
|
||||
|
||||
### Cleanup Event Listeners
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
console.log('Resized');
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize); // Cleanup!
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
### Abort Controllers for Fetch
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
fetch('/api/data', { signal: abortController.signal })
|
||||
.then(response => response.json())
|
||||
.then(data => setState(data))
|
||||
.catch(error => {
|
||||
if (error.name === 'AbortError') {
|
||||
console.log('Fetch aborted');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
abortController.abort(); // Cleanup!
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
**Note**: With TanStack Query, this is handled automatically.
|
||||
|
||||
---
|
||||
|
||||
## Form Performance
|
||||
|
||||
### Watch Specific Fields (Not All)
|
||||
|
||||
```typescript
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
export const MyForm: React.FC = () => {
|
||||
const { register, watch, handleSubmit } = useForm();
|
||||
|
||||
// ❌ AVOID - Watches all fields, re-renders on any change
|
||||
const formValues = watch();
|
||||
|
||||
// ✅ CORRECT - Watch only what you need
|
||||
const username = watch('username');
|
||||
const email = watch('email');
|
||||
|
||||
// Or multiple specific fields
|
||||
const [username, email] = watch(['username', 'email']);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<input {...register('username')} />
|
||||
<input {...register('email')} />
|
||||
<input {...register('password')} />
|
||||
|
||||
{/* Only re-renders when username/email change */}
|
||||
<p>Username: {username}, Email: {email}</p>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## List Rendering Optimization
|
||||
|
||||
### Key Prop Usage
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Stable unique keys
|
||||
{items.map(item => (
|
||||
<ListItem key={item.id}>
|
||||
{item.name}
|
||||
</ListItem>
|
||||
))}
|
||||
|
||||
// ❌ AVOID - Index as key (unstable if list changes)
|
||||
{items.map((item, index) => (
|
||||
<ListItem key={index}> // WRONG if list reorders
|
||||
{item.name}
|
||||
</ListItem>
|
||||
))}
|
||||
```
|
||||
|
||||
### Memoized List Items
|
||||
|
||||
```typescript
|
||||
const ListItem = React.memo<ListItemProps>(({ item, onAction }) => {
|
||||
return (
|
||||
<Box onClick={() => onAction(item.id)}>
|
||||
{item.name}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
export const List: React.FC<{ items: Item[] }> = ({ items }) => {
|
||||
const handleAction = useCallback((id: string) => {
|
||||
console.log('Action:', id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{items.map(item => (
|
||||
<ListItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Preventing Component Re-initialization
|
||||
|
||||
### The Problem
|
||||
|
||||
```typescript
|
||||
// ❌ AVOID - Component recreated on every render
|
||||
export const Parent: React.FC = () => {
|
||||
// New component definition each render!
|
||||
const ChildComponent = () => <div>Child</div>;
|
||||
|
||||
return <ChildComponent />; // Unmounts and remounts every render
|
||||
};
|
||||
```
|
||||
|
||||
### The Solution
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Define outside or use useMemo
|
||||
const ChildComponent: React.FC = () => <div>Child</div>;
|
||||
|
||||
export const Parent: React.FC = () => {
|
||||
return <ChildComponent />; // Stable component
|
||||
};
|
||||
|
||||
// ✅ OR if dynamic, use useMemo
|
||||
export const Parent: React.FC<{ config: Config }> = ({ config }) => {
|
||||
const DynamicComponent = useMemo(() => {
|
||||
return () => <div>{config.title}</div>;
|
||||
}, [config.title]);
|
||||
|
||||
return <DynamicComponent />;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lazy Loading Heavy Dependencies
|
||||
|
||||
### Code Splitting
|
||||
|
||||
```typescript
|
||||
// ❌ AVOID - Import heavy libraries at top level
|
||||
import jsPDF from 'jspdf'; // Large library loaded immediately
|
||||
import * as XLSX from 'xlsx'; // Large library loaded immediately
|
||||
|
||||
// ✅ CORRECT - Dynamic import when needed
|
||||
const handleExportPDF = async () => {
|
||||
const { jsPDF } = await import('jspdf');
|
||||
const doc = new jsPDF();
|
||||
// Use it
|
||||
};
|
||||
|
||||
const handleExportExcel = async () => {
|
||||
const XLSX = await import('xlsx');
|
||||
// Use it
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Performance Checklist:**
|
||||
- ✅ `useMemo` for expensive computations (filter, sort, map)
|
||||
- ✅ `useCallback` for functions passed to children
|
||||
- ✅ `React.memo` for expensive components
|
||||
- ✅ Debounce search/filter (300-500ms)
|
||||
- ✅ Cleanup timeouts/intervals in useEffect
|
||||
- ✅ Watch specific form fields (not all)
|
||||
- ✅ Stable keys in lists
|
||||
- ✅ Lazy load heavy libraries
|
||||
- ✅ Code splitting with React.lazy
|
||||
|
||||
**See Also:**
|
||||
- [component-patterns.md](component-patterns.md) - Lazy loading
|
||||
- [data-fetching.md](data-fetching.md) - TanStack Query optimization
|
||||
- [complete-examples.md](complete-examples.md) - Performance patterns in context
|
||||
364
.opencode/skills/frontend-development/resources/routing-guide.md
Normal file
364
.opencode/skills/frontend-development/resources/routing-guide.md
Normal file
@@ -0,0 +1,364 @@
|
||||
# Routing Guide
|
||||
|
||||
TanStack Router implementation with folder-based routing and lazy loading patterns.
|
||||
|
||||
---
|
||||
|
||||
## TanStack Router Overview
|
||||
|
||||
**TanStack Router** with file-based routing:
|
||||
- Folder structure defines routes
|
||||
- Lazy loading for code splitting
|
||||
- Type-safe routing
|
||||
- Breadcrumb loaders
|
||||
|
||||
---
|
||||
|
||||
## Folder-Based Routing
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
routes/
|
||||
__root.tsx # Root layout
|
||||
index.tsx # Home route (/)
|
||||
posts/
|
||||
index.tsx # /posts
|
||||
create/
|
||||
index.tsx # /posts/create
|
||||
$postId.tsx # /posts/:postId (dynamic)
|
||||
comments/
|
||||
index.tsx # /comments
|
||||
```
|
||||
|
||||
**Pattern**:
|
||||
- `index.tsx` = Route at that path
|
||||
- `$param.tsx` = Dynamic parameter
|
||||
- Nested folders = Nested routes
|
||||
|
||||
---
|
||||
|
||||
## Basic Route Pattern
|
||||
|
||||
### Example from posts/index.tsx
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Posts route component
|
||||
* Displays the main blog posts list
|
||||
*/
|
||||
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { lazy } from 'react';
|
||||
|
||||
// Lazy load the page component
|
||||
const PostsList = lazy(() =>
|
||||
import('@/features/posts/components/PostsList').then(
|
||||
(module) => ({ default: module.PostsList }),
|
||||
),
|
||||
);
|
||||
|
||||
export const Route = createFileRoute('/posts/')({
|
||||
component: PostsPage,
|
||||
// Define breadcrumb data
|
||||
loader: () => ({
|
||||
crumb: 'Posts',
|
||||
}),
|
||||
});
|
||||
|
||||
function PostsPage() {
|
||||
return (
|
||||
<PostsList
|
||||
title='All Posts'
|
||||
showFilters={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default PostsPage;
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Lazy load heavy components
|
||||
- `createFileRoute` with route path
|
||||
- `loader` for breadcrumb data
|
||||
- Page component renders content
|
||||
- Export both Route and component
|
||||
|
||||
---
|
||||
|
||||
## Lazy Loading Routes
|
||||
|
||||
### Named Export Pattern
|
||||
|
||||
```typescript
|
||||
import { lazy } from 'react';
|
||||
|
||||
// For named exports, use .then() to map to default
|
||||
const MyPage = lazy(() =>
|
||||
import('@/features/my-feature/components/MyPage').then(
|
||||
(module) => ({ default: module.MyPage })
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
### Default Export Pattern
|
||||
|
||||
```typescript
|
||||
import { lazy } from 'react';
|
||||
|
||||
// For default exports, simpler syntax
|
||||
const MyPage = lazy(() => import('@/features/my-feature/components/MyPage'));
|
||||
```
|
||||
|
||||
### Why Lazy Load Routes?
|
||||
|
||||
- Code splitting - smaller initial bundle
|
||||
- Faster initial page load
|
||||
- Load route code only when navigated to
|
||||
- Better performance
|
||||
|
||||
---
|
||||
|
||||
## createFileRoute
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
```typescript
|
||||
export const Route = createFileRoute('/my-route/')({
|
||||
component: MyRoutePage,
|
||||
});
|
||||
|
||||
function MyRoutePage() {
|
||||
return <div>My Route Content</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### With Breadcrumb Loader
|
||||
|
||||
```typescript
|
||||
export const Route = createFileRoute('/my-route/')({
|
||||
component: MyRoutePage,
|
||||
loader: () => ({
|
||||
crumb: 'My Route Title',
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
Breadcrumb appears in navigation/app bar automatically.
|
||||
|
||||
### With Data Loader
|
||||
|
||||
```typescript
|
||||
export const Route = createFileRoute('/my-route/')({
|
||||
component: MyRoutePage,
|
||||
loader: async () => {
|
||||
// Can prefetch data here
|
||||
const data = await api.getData();
|
||||
return { crumb: 'My Route', data };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### With Search Params
|
||||
|
||||
```typescript
|
||||
export const Route = createFileRoute('/search/')({
|
||||
component: SearchPage,
|
||||
validateSearch: (search: Record<string, unknown>) => {
|
||||
return {
|
||||
query: (search.query as string) || '',
|
||||
page: Number(search.page) || 1,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function SearchPage() {
|
||||
const { query, page } = Route.useSearch();
|
||||
// Use query and page
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dynamic Routes
|
||||
|
||||
### Parameter Routes
|
||||
|
||||
```typescript
|
||||
// routes/users/$userId.tsx
|
||||
|
||||
export const Route = createFileRoute('/users/$userId')({
|
||||
component: UserPage,
|
||||
});
|
||||
|
||||
function UserPage() {
|
||||
const { userId } = Route.useParams();
|
||||
|
||||
return <UserProfile userId={userId} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Parameters
|
||||
|
||||
```typescript
|
||||
// routes/posts/$postId/comments/$commentId.tsx
|
||||
|
||||
export const Route = createFileRoute('/posts/$postId/comments/$commentId')({
|
||||
component: CommentPage,
|
||||
});
|
||||
|
||||
function CommentPage() {
|
||||
const { postId, commentId } = Route.useParams();
|
||||
|
||||
return <CommentEditor postId={postId} commentId={commentId} />;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Navigation
|
||||
|
||||
### Programmatic Navigation
|
||||
|
||||
```typescript
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
|
||||
export const MyComponent: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClick = () => {
|
||||
navigate({ to: '/posts' });
|
||||
};
|
||||
|
||||
return <Button onClick={handleClick}>View Posts</Button>;
|
||||
};
|
||||
```
|
||||
|
||||
### With Parameters
|
||||
|
||||
```typescript
|
||||
const handleNavigate = () => {
|
||||
navigate({
|
||||
to: '/users/$userId',
|
||||
params: { userId: '123' },
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### With Search Params
|
||||
|
||||
```typescript
|
||||
const handleSearch = () => {
|
||||
navigate({
|
||||
to: '/search',
|
||||
search: { query: 'test', page: 1 },
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Route Layout Pattern
|
||||
|
||||
### Root Layout (__root.tsx)
|
||||
|
||||
```typescript
|
||||
import { createRootRoute, Outlet } from '@tanstack/react-router';
|
||||
import { Box } from '@mui/material';
|
||||
import { CustomAppBar } from '~components/CustomAppBar';
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: RootLayout,
|
||||
});
|
||||
|
||||
function RootLayout() {
|
||||
return (
|
||||
<Box>
|
||||
<CustomAppBar />
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Outlet /> {/* Child routes render here */}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Nested Layouts
|
||||
|
||||
```typescript
|
||||
// routes/dashboard/index.tsx
|
||||
export const Route = createFileRoute('/dashboard/')({
|
||||
component: DashboardLayout,
|
||||
});
|
||||
|
||||
function DashboardLayout() {
|
||||
return (
|
||||
<Box>
|
||||
<DashboardSidebar />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Outlet /> {/* Nested routes */}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Route Example
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* User profile route
|
||||
* Path: /users/:userId
|
||||
*/
|
||||
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { lazy } from 'react';
|
||||
import { SuspenseLoader } from '~components/SuspenseLoader';
|
||||
|
||||
// Lazy load heavy component
|
||||
const UserProfile = lazy(() =>
|
||||
import('@/features/users/components/UserProfile').then(
|
||||
(module) => ({ default: module.UserProfile })
|
||||
)
|
||||
);
|
||||
|
||||
export const Route = createFileRoute('/users/$userId')({
|
||||
component: UserPage,
|
||||
loader: () => ({
|
||||
crumb: 'User Profile',
|
||||
}),
|
||||
});
|
||||
|
||||
function UserPage() {
|
||||
const { userId } = Route.useParams();
|
||||
|
||||
return (
|
||||
<SuspenseLoader>
|
||||
<UserProfile userId={userId} />
|
||||
</SuspenseLoader>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserPage;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Routing Checklist:**
|
||||
- ✅ Folder-based: `routes/my-route/index.tsx`
|
||||
- ✅ Lazy load components: `React.lazy(() => import())`
|
||||
- ✅ Use `createFileRoute` with route path
|
||||
- ✅ Add breadcrumb in `loader` function
|
||||
- ✅ Wrap in `SuspenseLoader` for loading states
|
||||
- ✅ Use `Route.useParams()` for dynamic params
|
||||
- ✅ Use `useNavigate()` for programmatic navigation
|
||||
|
||||
**See Also:**
|
||||
- [component-patterns.md](component-patterns.md) - Lazy loading patterns
|
||||
- [loading-and-error-states.md](loading-and-error-states.md) - SuspenseLoader usage
|
||||
- [complete-examples.md](complete-examples.md) - Full route examples
|
||||
428
.opencode/skills/frontend-development/resources/styling-guide.md
Normal file
428
.opencode/skills/frontend-development/resources/styling-guide.md
Normal file
@@ -0,0 +1,428 @@
|
||||
# Styling Guide
|
||||
|
||||
Modern styling patterns for using MUI v7 sx prop, inline styles, and theme integration.
|
||||
|
||||
---
|
||||
|
||||
## Inline vs Separate Styles
|
||||
|
||||
### Decision Threshold
|
||||
|
||||
**<100 lines: Inline styles at top of component**
|
||||
|
||||
```typescript
|
||||
import type { SxProps, Theme } from '@mui/material';
|
||||
|
||||
const componentStyles: Record<string, SxProps<Theme>> = {
|
||||
container: {
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
header: {
|
||||
mb: 2,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
},
|
||||
// ... more styles
|
||||
};
|
||||
|
||||
export const MyComponent: React.FC = () => {
|
||||
return (
|
||||
<Box sx={componentStyles.container}>
|
||||
<Box sx={componentStyles.header}>
|
||||
<h2>Title</h2>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**>100 lines: Separate `.styles.ts` file**
|
||||
|
||||
```typescript
|
||||
// MyComponent.styles.ts
|
||||
import type { SxProps, Theme } from '@mui/material';
|
||||
|
||||
export const componentStyles: Record<string, SxProps<Theme>> = {
|
||||
container: { ... },
|
||||
header: { ... },
|
||||
// ... 100+ lines of styles
|
||||
};
|
||||
|
||||
// MyComponent.tsx
|
||||
import { componentStyles } from './MyComponent.styles';
|
||||
|
||||
export const MyComponent: React.FC = () => {
|
||||
return <Box sx={componentStyles.container}>...</Box>;
|
||||
};
|
||||
```
|
||||
|
||||
### Real Example: UnifiedForm.tsx
|
||||
|
||||
**Lines 48-126**: 78 lines of inline styles (acceptable)
|
||||
|
||||
```typescript
|
||||
const formStyles: Record<string, SxProps<Theme>> = {
|
||||
gridContainer: {
|
||||
height: '100%',
|
||||
maxHeight: 'calc(100vh - 220px)',
|
||||
},
|
||||
section: {
|
||||
height: '100%',
|
||||
maxHeight: 'calc(100vh - 220px)',
|
||||
overflow: 'auto',
|
||||
p: 4,
|
||||
},
|
||||
// ... 15 more style objects
|
||||
};
|
||||
```
|
||||
|
||||
**Guideline**: User is comfortable with ~80 lines inline. Use your judgment around 100 lines.
|
||||
|
||||
---
|
||||
|
||||
## sx Prop Patterns
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
<Box sx={{ p: 2, mb: 3, display: 'flex' }}>
|
||||
Content
|
||||
</Box>
|
||||
```
|
||||
|
||||
### With Theme Access
|
||||
|
||||
```typescript
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: (theme) => theme.palette.primary.main,
|
||||
color: (theme) => theme.palette.primary.contrastText,
|
||||
borderRadius: (theme) => theme.shape.borderRadius,
|
||||
}}
|
||||
>
|
||||
Themed Box
|
||||
</Box>
|
||||
```
|
||||
|
||||
### Responsive Styles
|
||||
|
||||
```typescript
|
||||
<Box
|
||||
sx={{
|
||||
p: { xs: 1, sm: 2, md: 3 },
|
||||
width: { xs: '100%', md: '50%' },
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
}}
|
||||
>
|
||||
Responsive Layout
|
||||
</Box>
|
||||
```
|
||||
|
||||
### Pseudo-Selectors
|
||||
|
||||
```typescript
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0,0,0,0.05)',
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: 'rgba(0,0,0,0.1)',
|
||||
},
|
||||
'& .child-class': {
|
||||
color: 'primary.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Interactive Box
|
||||
</Box>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MUI v7 Patterns
|
||||
|
||||
### Grid Component (v7 Syntax)
|
||||
|
||||
```typescript
|
||||
import { Grid } from '@mui/material';
|
||||
|
||||
// ✅ CORRECT - v7 syntax with size prop
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
Left Column
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
Right Column
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
// ❌ WRONG - Old v6 syntax
|
||||
<Grid container spacing={2}>
|
||||
<Grid xs={12} md={6}> {/* OLD - Don't use */}
|
||||
Content
|
||||
</Grid>
|
||||
</Grid>
|
||||
```
|
||||
|
||||
**Key Change**: `size={{ xs: 12, md: 6 }}` instead of `xs={12} md={6}`
|
||||
|
||||
### Responsive Grid
|
||||
|
||||
```typescript
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }}>
|
||||
Responsive Column
|
||||
</Grid>
|
||||
</Grid>
|
||||
```
|
||||
|
||||
### Nested Grids
|
||||
|
||||
```typescript
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, md: 8 }}>
|
||||
<Grid container spacing={1}>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
Nested 1
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
Nested 2
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
Sidebar
|
||||
</Grid>
|
||||
</Grid>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Type-Safe Styles
|
||||
|
||||
### Style Object Type
|
||||
|
||||
```typescript
|
||||
import type { SxProps, Theme } from '@mui/material';
|
||||
|
||||
// Type-safe styles
|
||||
const styles: Record<string, SxProps<Theme>> = {
|
||||
container: {
|
||||
p: 2,
|
||||
// Autocomplete and type checking work here
|
||||
},
|
||||
};
|
||||
|
||||
// Or individual style
|
||||
const containerStyle: SxProps<Theme> = {
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
};
|
||||
```
|
||||
|
||||
### Theme-Aware Styles
|
||||
|
||||
```typescript
|
||||
const styles: Record<string, SxProps<Theme>> = {
|
||||
primary: {
|
||||
color: (theme) => theme.palette.primary.main,
|
||||
backgroundColor: (theme) => theme.palette.primary.light,
|
||||
'&:hover': {
|
||||
backgroundColor: (theme) => theme.palette.primary.dark,
|
||||
},
|
||||
},
|
||||
customSpacing: {
|
||||
padding: (theme) => theme.spacing(2),
|
||||
margin: (theme) => theme.spacing(1, 2), // top/bottom: 1, left/right: 2
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What NOT to Use
|
||||
|
||||
### ❌ makeStyles (MUI v4 pattern)
|
||||
|
||||
```typescript
|
||||
// ❌ AVOID - Old Material-UI v4 pattern
|
||||
import { makeStyles } from '@mui/styles';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
padding: theme.spacing(2),
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
**Why avoid**: Deprecated, v7 doesn't support it well
|
||||
|
||||
### ❌ styled() Components
|
||||
|
||||
```typescript
|
||||
// ❌ AVOID - styled-components pattern
|
||||
import { styled } from '@mui/material/styles';
|
||||
|
||||
const StyledBox = styled(Box)(({ theme }) => ({
|
||||
padding: theme.spacing(2),
|
||||
}));
|
||||
```
|
||||
|
||||
**Why avoid**: sx prop is more flexible and doesn't create new components
|
||||
|
||||
### ✅ Use sx Prop Instead
|
||||
|
||||
```typescript
|
||||
// ✅ PREFERRED
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: 'primary.main',
|
||||
}}
|
||||
>
|
||||
Content
|
||||
</Box>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Style Standards
|
||||
|
||||
### Indentation
|
||||
|
||||
**4 spaces** (not 2, not tabs)
|
||||
|
||||
```typescript
|
||||
const styles: Record<string, SxProps<Theme>> = {
|
||||
container: {
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Quotes
|
||||
|
||||
**Single quotes** for strings (project standard)
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT
|
||||
const color = 'primary.main';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
// ❌ WRONG
|
||||
const color = "primary.main";
|
||||
import { Box } from "@mui/material";
|
||||
```
|
||||
|
||||
### Trailing Commas
|
||||
|
||||
**Always use trailing commas** in objects and arrays
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT
|
||||
const styles = {
|
||||
container: { p: 2 },
|
||||
header: { mb: 1 }, // Trailing comma
|
||||
};
|
||||
|
||||
const items = [
|
||||
'item1',
|
||||
'item2', // Trailing comma
|
||||
];
|
||||
|
||||
// ❌ WRONG - No trailing comma
|
||||
const styles = {
|
||||
container: { p: 2 },
|
||||
header: { mb: 1 } // Missing comma
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Style Patterns
|
||||
|
||||
### Flexbox Layout
|
||||
|
||||
```typescript
|
||||
const styles = {
|
||||
flexRow: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
},
|
||||
flexColumn: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
},
|
||||
spaceBetween: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Spacing
|
||||
|
||||
```typescript
|
||||
// Padding
|
||||
p: 2 // All sides
|
||||
px: 2 // Horizontal (left + right)
|
||||
py: 2 // Vertical (top + bottom)
|
||||
pt: 2, pr: 1 // Specific sides
|
||||
|
||||
// Margin
|
||||
m: 2, mx: 2, my: 2, mt: 2, mr: 1
|
||||
|
||||
// Units: 1 = 8px (theme.spacing(1))
|
||||
p: 2 // = 16px
|
||||
p: 0.5 // = 4px
|
||||
```
|
||||
|
||||
### Positioning
|
||||
|
||||
```typescript
|
||||
const styles = {
|
||||
relative: {
|
||||
position: 'relative',
|
||||
},
|
||||
absolute: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
},
|
||||
sticky: {
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 1000,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Styling Checklist:**
|
||||
- ✅ Use `sx` prop for MUI styling
|
||||
- ✅ Type-safe with `SxProps<Theme>`
|
||||
- ✅ <100 lines: inline; >100 lines: separate file
|
||||
- ✅ MUI v7 Grid: `size={{ xs: 12 }}`
|
||||
- ✅ 4 space indentation
|
||||
- ✅ Single quotes
|
||||
- ✅ Trailing commas
|
||||
- ❌ No makeStyles or styled()
|
||||
|
||||
**See Also:**
|
||||
- [component-patterns.md](component-patterns.md) - Component structure
|
||||
- [complete-examples.md](complete-examples.md) - Full styling examples
|
||||
@@ -0,0 +1,418 @@
|
||||
# TypeScript Standards
|
||||
|
||||
TypeScript best practices for type safety and maintainability in React frontend code.
|
||||
|
||||
---
|
||||
|
||||
## Strict Mode
|
||||
|
||||
### Configuration
|
||||
|
||||
TypeScript strict mode is **enabled** in the project:
|
||||
|
||||
```json
|
||||
// tsconfig.json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**This means:**
|
||||
- No implicit `any` types
|
||||
- Null/undefined must be handled explicitly
|
||||
- Type safety enforced
|
||||
|
||||
---
|
||||
|
||||
## No `any` Type
|
||||
|
||||
### The Rule
|
||||
|
||||
```typescript
|
||||
// ❌ NEVER use any
|
||||
function handleData(data: any) {
|
||||
return data.something;
|
||||
}
|
||||
|
||||
// ✅ Use specific types
|
||||
interface MyData {
|
||||
something: string;
|
||||
}
|
||||
|
||||
function handleData(data: MyData) {
|
||||
return data.something;
|
||||
}
|
||||
|
||||
// ✅ Or use unknown for truly unknown data
|
||||
function handleUnknown(data: unknown) {
|
||||
if (typeof data === 'object' && data !== null && 'something' in data) {
|
||||
return (data as MyData).something;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**If you truly don't know the type:**
|
||||
- Use `unknown` (forces type checking)
|
||||
- Use type guards to narrow
|
||||
- Document why type is unknown
|
||||
|
||||
---
|
||||
|
||||
## Explicit Return Types
|
||||
|
||||
### Function Return Types
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Explicit return type
|
||||
function getUser(id: number): Promise<User> {
|
||||
return apiClient.get(`/users/${id}`);
|
||||
}
|
||||
|
||||
function calculateTotal(items: Item[]): number {
|
||||
return items.reduce((sum, item) => sum + item.price, 0);
|
||||
}
|
||||
|
||||
// ❌ AVOID - Implicit return type (less clear)
|
||||
function getUser(id: number) {
|
||||
return apiClient.get(`/users/${id}`);
|
||||
}
|
||||
```
|
||||
|
||||
### Component Return Types
|
||||
|
||||
```typescript
|
||||
// React.FC already provides return type (ReactElement)
|
||||
export const MyComponent: React.FC<Props> = ({ prop }) => {
|
||||
return <div>{prop}</div>;
|
||||
};
|
||||
|
||||
// For custom hooks
|
||||
function useMyData(id: number): { data: Data; isLoading: boolean } {
|
||||
const [data, setData] = useState<Data | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
return { data: data!, isLoading };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Type Imports
|
||||
|
||||
### Use 'type' Keyword
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Explicitly mark as type import
|
||||
import type { User } from '~types/user';
|
||||
import type { Post } from '~types/post';
|
||||
import type { SxProps, Theme } from '@mui/material';
|
||||
|
||||
// ❌ AVOID - Mixed value and type imports
|
||||
import { User } from '~types/user'; // Unclear if type or value
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Clearly separates types from values
|
||||
- Better tree-shaking
|
||||
- Prevents circular dependencies
|
||||
- TypeScript compiler optimization
|
||||
|
||||
---
|
||||
|
||||
## Component Prop Interfaces
|
||||
|
||||
### Interface Pattern
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Props for MyComponent
|
||||
*/
|
||||
interface MyComponentProps {
|
||||
/** The user ID to display */
|
||||
userId: number;
|
||||
|
||||
/** Optional callback when action completes */
|
||||
onComplete?: () => void;
|
||||
|
||||
/** Display mode for the component */
|
||||
mode?: 'view' | 'edit';
|
||||
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const MyComponent: React.FC<MyComponentProps> = ({
|
||||
userId,
|
||||
onComplete,
|
||||
mode = 'view', // Default value
|
||||
className,
|
||||
}) => {
|
||||
return <div>...</div>;
|
||||
};
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Separate interface for props
|
||||
- JSDoc comments for each prop
|
||||
- Optional props use `?`
|
||||
- Provide defaults in destructuring
|
||||
|
||||
### Props with Children
|
||||
|
||||
```typescript
|
||||
interface ContainerProps {
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
}
|
||||
|
||||
// React.FC automatically includes children type, but be explicit
|
||||
export const Container: React.FC<ContainerProps> = ({ children, title }) => {
|
||||
return (
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Utility Types
|
||||
|
||||
### Partial<T>
|
||||
|
||||
```typescript
|
||||
// Make all properties optional
|
||||
type UserUpdate = Partial<User>;
|
||||
|
||||
function updateUser(id: number, updates: Partial<User>) {
|
||||
// updates can have any subset of User properties
|
||||
}
|
||||
```
|
||||
|
||||
### Pick<T, K>
|
||||
|
||||
```typescript
|
||||
// Select specific properties
|
||||
type UserPreview = Pick<User, 'id' | 'name' | 'email'>;
|
||||
|
||||
const preview: UserPreview = {
|
||||
id: 1,
|
||||
name: 'John',
|
||||
email: 'john@example.com',
|
||||
// Other User properties not allowed
|
||||
};
|
||||
```
|
||||
|
||||
### Omit<T, K>
|
||||
|
||||
```typescript
|
||||
// Exclude specific properties
|
||||
type UserWithoutPassword = Omit<User, 'password' | 'passwordHash'>;
|
||||
|
||||
const publicUser: UserWithoutPassword = {
|
||||
id: 1,
|
||||
name: 'John',
|
||||
email: 'john@example.com',
|
||||
// password and passwordHash not allowed
|
||||
};
|
||||
```
|
||||
|
||||
### Required<T>
|
||||
|
||||
```typescript
|
||||
// Make all properties required
|
||||
type RequiredConfig = Required<Config>; // All optional props become required
|
||||
```
|
||||
|
||||
### Record<K, V>
|
||||
|
||||
```typescript
|
||||
// Type-safe object/map
|
||||
const userMap: Record<string, User> = {
|
||||
'user1': { id: 1, name: 'John' },
|
||||
'user2': { id: 2, name: 'Jane' },
|
||||
};
|
||||
|
||||
// For styles
|
||||
import type { SxProps, Theme } from '@mui/material';
|
||||
|
||||
const styles: Record<string, SxProps<Theme>> = {
|
||||
container: { p: 2 },
|
||||
header: { mb: 1 },
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Type Guards
|
||||
|
||||
### Basic Type Guards
|
||||
|
||||
```typescript
|
||||
function isUser(data: unknown): data is User {
|
||||
return (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'id' in data &&
|
||||
'name' in data
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
if (isUser(response)) {
|
||||
console.log(response.name); // TypeScript knows it's User
|
||||
}
|
||||
```
|
||||
|
||||
### Discriminated Unions
|
||||
|
||||
```typescript
|
||||
type LoadingState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading' }
|
||||
| { status: 'success'; data: Data }
|
||||
| { status: 'error'; error: Error };
|
||||
|
||||
function Component({ state }: { state: LoadingState }) {
|
||||
// TypeScript narrows type based on status
|
||||
if (state.status === 'success') {
|
||||
return <Display data={state.data} />; // data available here
|
||||
}
|
||||
|
||||
if (state.status === 'error') {
|
||||
return <Error error={state.error} />; // error available here
|
||||
}
|
||||
|
||||
return <Loading />;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Generic Types
|
||||
|
||||
### Generic Functions
|
||||
|
||||
```typescript
|
||||
function getById<T>(items: T[], id: number): T | undefined {
|
||||
return items.find(item => (item as any).id === id);
|
||||
}
|
||||
|
||||
// Usage with type inference
|
||||
const users: User[] = [...];
|
||||
const user = getById(users, 123); // Type: User | undefined
|
||||
```
|
||||
|
||||
### Generic Components
|
||||
|
||||
```typescript
|
||||
interface ListProps<T> {
|
||||
items: T[];
|
||||
renderItem: (item: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function List<T>({ items, renderItem }: ListProps<T>): React.ReactElement {
|
||||
return (
|
||||
<div>
|
||||
{items.map((item, index) => (
|
||||
<div key={index}>{renderItem(item)}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
<List<User>
|
||||
items={users}
|
||||
renderItem={(user) => <UserCard user={user} />}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Type Assertions (Use Sparingly)
|
||||
|
||||
### When to Use
|
||||
|
||||
```typescript
|
||||
// ✅ OK - When you know more than TypeScript
|
||||
const element = document.getElementById('my-element') as HTMLInputElement;
|
||||
const value = element.value;
|
||||
|
||||
// ✅ OK - API response that you've validated
|
||||
const response = await api.getData();
|
||||
const user = response.data as User; // You know the shape
|
||||
```
|
||||
|
||||
### When NOT to Use
|
||||
|
||||
```typescript
|
||||
// ❌ AVOID - Circumventing type safety
|
||||
const data = getData() as any; // WRONG - defeats TypeScript
|
||||
|
||||
// ❌ AVOID - Unsafe assertion
|
||||
const value = unknownValue as string; // Might not actually be string
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Null/Undefined Handling
|
||||
|
||||
### Optional Chaining
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT
|
||||
const name = user?.profile?.name;
|
||||
|
||||
// Equivalent to:
|
||||
const name = user && user.profile && user.profile.name;
|
||||
```
|
||||
|
||||
### Nullish Coalescing
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT
|
||||
const displayName = user?.name ?? 'Anonymous';
|
||||
|
||||
// Only uses default if null or undefined
|
||||
// (Different from || which triggers on '', 0, false)
|
||||
```
|
||||
|
||||
### Non-Null Assertion (Use Carefully)
|
||||
|
||||
```typescript
|
||||
// ✅ OK - When you're certain value exists
|
||||
const data = queryClient.getQueryData<Data>(['data'])!;
|
||||
|
||||
// ⚠️ CAREFUL - Only use when you KNOW it's not null
|
||||
// Better to check explicitly:
|
||||
const data = queryClient.getQueryData<Data>(['data']);
|
||||
if (data) {
|
||||
// Use data
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**TypeScript Checklist:**
|
||||
- ✅ Strict mode enabled
|
||||
- ✅ No `any` type (use `unknown` if needed)
|
||||
- ✅ Explicit return types on functions
|
||||
- ✅ Use `import type` for type imports
|
||||
- ✅ JSDoc comments on prop interfaces
|
||||
- ✅ Utility types (Partial, Pick, Omit, Required, Record)
|
||||
- ✅ Type guards for narrowing
|
||||
- ✅ Optional chaining and nullish coalescing
|
||||
- ❌ Avoid type assertions unless necessary
|
||||
|
||||
**See Also:**
|
||||
- [component-patterns.md](component-patterns.md) - Component typing
|
||||
- [data-fetching.md](data-fetching.md) - API typing
|
||||
Reference in New Issue
Block a user