Initial commit: The Ultimate Antigravity Skills Collection (58 Skills)
This commit is contained in:
399
skills/frontend-dev-guidelines/SKILL.md
Normal file
399
skills/frontend-dev-guidelines/SKILL.md
Normal file
@@ -0,0 +1,399 @@
|
||||
---
|
||||
name: frontend-dev-guidelines
|
||||
description: Frontend development guidelines for React/TypeScript applications. Modern patterns including Suspense, lazy loading, useSuspenseQuery, file organization with features directory, MUI v7 styling, TanStack Router, performance optimization, and TypeScript best practices. Use when creating components, pages, features, fetching data, styling, routing, or working with frontend code.
|
||||
---
|
||||
|
||||
# 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
|
||||
331
skills/frontend-dev-guidelines/resources/common-patterns.md
Normal file
331
skills/frontend-dev-guidelines/resources/common-patterns.md
Normal file
@@ -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
|
||||
872
skills/frontend-dev-guidelines/resources/complete-examples.md
Normal file
872
skills/frontend-dev-guidelines/resources/complete-examples.md
Normal file
@@ -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.**
|
||||
502
skills/frontend-dev-guidelines/resources/component-patterns.md
Normal file
502
skills/frontend-dev-guidelines/resources/component-patterns.md
Normal file
@@ -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
skills/frontend-dev-guidelines/resources/data-fetching.md
Normal file
767
skills/frontend-dev-guidelines/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
|
||||
502
skills/frontend-dev-guidelines/resources/file-organization.md
Normal file
502
skills/frontend-dev-guidelines/resources/file-organization.md
Normal file
@@ -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
skills/frontend-dev-guidelines/resources/performance.md
Normal file
406
skills/frontend-dev-guidelines/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
skills/frontend-dev-guidelines/resources/routing-guide.md
Normal file
364
skills/frontend-dev-guidelines/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
skills/frontend-dev-guidelines/resources/styling-guide.md
Normal file
428
skills/frontend-dev-guidelines/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
|
||||
418
skills/frontend-dev-guidelines/resources/typescript-standards.md
Normal file
418
skills/frontend-dev-guidelines/resources/typescript-standards.md
Normal file
@@ -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