
Frontend Dev Guidelines
- 702 installs
- 29.9k repo stars
- Updated July 27, 2026
- davila7/claude-code-templates
frontend-dev-guidelines is a Claude Code skill that provides proven React, TypeScript, MUI, and React Hook Form patterns so developers avoid rewriting common UI components from scratch.
About
frontend-dev-guidelines is a skill from davila7/claude-code-templates that gives instant access to proven React, TypeScript, MUI, and React Hook Form patterns for common UI work. The readme documents authentication with useAuth, DataGrid setups, dialogs, and form flows with explicit guidance such as never making direct API calls for auth. Developers reach for it when scaffolding SaaS dashboards, admin panels, or internal tools that repeat the same MUI and hook-form structures. The skill encodes copy-paste-ready conventions instead of one-off component reinvention.
- Ready-to-copy patterns for authentication with useAuth hook
- React Hook Form + Zod validation forms with MUI components
- Standard implementations for DataGrids, dialogs and snackbar notifications
- Explicit rules such as NEVER make direct API calls for auth
- Consistent patterns that reduce UI bugs across projects
Frontend Dev Guidelines by the numbers
- 702 all-time installs (skills.sh)
- +23 installs in the week ending Jul 12, 2026 (Skillselion tracking)
- Ranked #484 of 2,277 Frontend Development skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/davila7/claude-code-templates --skill frontend-dev-guidelinesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 702 |
|---|---|
| repo stars | ★ 29.9k |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 27, 2026 |
| Repository | davila7/claude-code-templates ↗ |
What are proven React MUI patterns for forms and auth?
Instantly access proven React, TypeScript, MUI and React Hook Form patterns instead of rewriting common UI components from scratch.
Who is it for?
React developers building MUI SaaS UIs who want standardized auth, form, and DataGrid patterns without rewriting boilerplate.
Skip if: Vue, Svelte, or Tailwind-only frontends that do not use React with MUI and React Hook Form.
When should I use this skill?
User builds React UI with MUI components, authentication hooks, DataGrid tables, dialogs, or React Hook Form flows.
What you get
React TypeScript components using useAuth, MUI DataGrid, React Hook Form flows, and dialog patterns from the guidelines.
- Auth-connected React components
- MUI form and DataGrid modules
Files
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
useSuspenseQueryfor data fetching - [ ] Import aliases:
@/,~types,~components,~features - [ ] Styles: Inline if <100 lines, separate file if >100 lines
- [ ] Use
useCallbackfor event handlers passed to children - [ ] Default export at bottom
- [ ] No early returns with loading spinners
- [ ] Use
useMuiSnackbarfor 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 lines 180-185
---
Common Imports Cheatsheet
// 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 safetyReact.lazy()for code splittingSuspenseLoaderfor 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
isLoadingchecks - Type-safe with generics
API Service Layer:
- Create
features/{feature}/api/{feature}Api.ts - Use
apiClientaxios 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.tsfile
Primary Method:
- Use
sxprop for MUI components - Type-safe with
SxProps<Theme> - Theme access:
(theme) => theme.palette.primary.main
MUI v7 Grid:
<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:
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
// ❌ 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
useMuiSnackbarfor user feedback - NEVER
react-toastify - TanStack Query
onErrorcallbacks
[📖 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 childrenReact.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
anytype - 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
useAuthhook 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 |
| Fetch data | data-fetching.md |
| Organize files/folders | file-organization.md |
| Style components | styling-guide.md |
| Set up routing | routing-guide.md |
| Handle loading/errors | loading-and-error-states.md |
| Optimize performance | performance.md |
| TypeScript types | typescript-standards.md |
| Forms/Auth/DataGrid | common-patterns.md |
| See full examples | 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)
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
---
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
Common Patterns
Frequently used patterns for forms, authentication, DataGrid, dialogs, and other common UI elements.
---
Authentication with useAuth
Getting Current User
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
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
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 arraycolumns: Column definitions- Loading/error states
Optional Props:
- Toolbar components
- Custom actions
- Initial state
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
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
// ✅ 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
// ✅ 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)
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 - TanStack Query patterns
- component-patterns.md - Component structure
- loading-and-error-states.md - Error handling
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
/**
* 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:
<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 exportsAPI Service (userApi.ts)
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)
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)
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)
// 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
/**
* 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
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
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
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:
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
/**
* 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
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
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
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.
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
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
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:
/**
* 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:
import { SuspenseLoader } from '~components/SuspenseLoader';
// Or
import { SuspenseLoader } from '@/components/SuspenseLoader';Usage:
<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:
// routes/my-route/index.tsx
const MyPage = lazy(() => import('@/features/my-feature/components/MyPage'));
function Route() {
return (
<SuspenseLoader>
<MyPage />
</SuspenseLoader>
);
}Component Level:
function ParentComponent() {
return (
<Box>
<Header />
<SuspenseLoader>
<HeavyDataGrid />
</SuspenseLoader>
</Box>
);
}Multiple Boundaries:
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
/**
* 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:
// ❌ 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)
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
const MyComponent = React.lazy(() =>
import('./MyComponent').then(module => ({
default: module.MyComponent
}))
);---
Component Communication
Props Down, Events Up
// 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:
// ❌ 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
// 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)
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 - useSuspenseQuery details
- loading-and-error-states.md - Suspense best practices
- complete-examples.md - Full working examples
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
isLoadingchecks needed - Integrates with Suspense boundaries
- Cleaner component code
- Consistent loading UX
- Better error handling with error boundaries
Basic Pattern
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 \ |
| 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:
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 freshgcTime: How long unused data stays in cacherefetchOnWindowFocus: false: User preference
---
Parallel Data Fetching
useSuspenseQueries
When fetching multiple independent resources:
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
// 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
// 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 layerService Pattern (from postApi.ts)
/**
* 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
// ✅ 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
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
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
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
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
// 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
import apiClient from '@/lib/apiClient';
// apiClient is a configured axios instance
// Automatically includes:
// - Base URL configuration
// - Cookie-based authentication
// - Error interceptors
// - Response transformersDo NOT create new axios instances - use apiClient for consistency.
---
Error Handling in Queries
onError Callback
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:
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
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
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
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
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
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
// 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
// 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 - Suspense integration
- loading-and-error-states.md - SuspenseLoader usage
- complete-examples.md - Full working examples
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 managementfeatures/blogs/- Blog builder and renderingfeatures/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 exportscomponents/ 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 wrappercomponents/CustomAppBar/- Application headercomponents/ErrorBoundary/- Error handlingcomponents/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 exportsSubdirectory Guidelines
api/ Directory
Purpose: Centralized API calls for the feature
Files:
{feature}Api.ts- Main API service
Pattern:
// 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.tsxhooks/ Directory
Purpose: Custom hooks for the feature
Naming:
useprefix (camelCase)- Descriptive of what they do
Examples:
hooks/
useMyFeature.ts # Main hook
useSuspenseMyFeature.ts # Suspense version
useMyFeatureMutations.ts # Mutations
useMyFeatureFilters.ts # Filters/searchhelpers/ Directory
Purpose: Utility functions specific to the feature
Examples:
helpers/
myFeatureHelpers.ts # General utilities
validation.ts # Validation logic
transblogers.ts # Data transblogations
constants.ts # Constantstypes/ 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
// ✅ 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):
import type { Post } from '~types/post';
import type { User, UserRole } from '~types/user';~components (Reusable Components):
import { SuspenseLoader } from '~components/SuspenseLoader';
import { CustomAppBar } from '~components/CustomAppBar';
import { ErrorBoundary } from '~components/ErrorBoundary';~features (Feature Imports):
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.tsxAvoid:
- 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.tsAPI Services
Pattern: camelCase with Api suffix, .ts extension
myFeatureApi.ts
postApi.ts
userApi.tsHelpers/Utilities
Pattern: camelCase with descriptive name, .ts extension
myFeatureHelpers.ts
validation.ts
transblogers.ts
constants.tsTypes
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)
// 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:
// 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:
// ✅ 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 structure
- data-fetching.md - API service patterns
- complete-examples.md - Full feature example
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
// ❌ 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)
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)
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
import { SuspenseLoader } from '~components/SuspenseLoader';
// Or
import { SuspenseLoader } from '@/components/SuspenseLoader';Basic Usage
<SuspenseLoader>
<LazyLoadedComponent />
</SuspenseLoader>With useSuspenseQuery
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
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
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
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
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 messageshowError(message)- Red error messageshowWarning(message)- Orange warning messageshowInfo(message)- Blue info message
TanStack Query Error Callbacks
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
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
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
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
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
// ❌ 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
// ✅ 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
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 - Suspense integration
- data-fetching.md - useSuspenseQuery details
Performance Optimization
Patterns for optimizing React component performance, preventing unnecessary re-renders, and avoiding memory leaks.
---
Memoization Patterns
useMemo for Expensive Computations
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
// ❌ 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
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
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
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
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
useEffect(() => {
const handleResize = () => {
console.log('Resized');
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize); // Cleanup!
};
}, []);Abort Controllers for Fetch
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)
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
// ✅ 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
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
// ❌ 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
// ✅ 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
// ❌ 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:
- ✅
useMemofor expensive computations (filter, sort, map) - ✅
useCallbackfor functions passed to children - ✅
React.memofor 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 - Lazy loading
- data-fetching.md - TanStack Query optimization
- complete-examples.md - Performance patterns in context
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 # /commentsPattern:
index.tsx= Route at that path$param.tsx= Dynamic parameter- Nested folders = Nested routes
---
Basic Route Pattern
Example from posts/index.tsx
/**
* 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
createFileRoutewith route pathloaderfor breadcrumb data- Page component renders content
- Export both Route and component
---
Lazy Loading Routes
Named Export Pattern
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
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
export const Route = createFileRoute('/my-route/')({
component: MyRoutePage,
});
function MyRoutePage() {
return <div>My Route Content</div>;
}With Breadcrumb Loader
export const Route = createFileRoute('/my-route/')({
component: MyRoutePage,
loader: () => ({
crumb: 'My Route Title',
}),
});Breadcrumb appears in navigation/app bar automatically.
With Data Loader
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
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
// routes/users/$userId.tsx
export const Route = createFileRoute('/users/$userId')({
component: UserPage,
});
function UserPage() {
const { userId } = Route.useParams();
return <UserProfile userId={userId} />;
}Multiple Parameters
// 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
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
const handleNavigate = () => {
navigate({
to: '/users/$userId',
params: { userId: '123' },
});
};With Search Params
const handleSearch = () => {
navigate({
to: '/search',
search: { query: 'test', page: 1 },
});
};---
Route Layout Pattern
Root Layout (__root.tsx)
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
// 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
/**
* 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
createFileRoutewith route path - ✅ Add breadcrumb in
loaderfunction - ✅ Wrap in
SuspenseLoaderfor loading states - ✅ Use
Route.useParams()for dynamic params - ✅ Use
useNavigate()for programmatic navigation
See Also:
- component-patterns.md - Lazy loading patterns
- loading-and-error-states.md - SuspenseLoader usage
- complete-examples.md - Full route examples
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
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
// 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)
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
<Box sx={{ p: 2, mb: 3, display: 'flex' }}>
Content
</Box>With Theme Access
<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
<Box
sx={{
p: { xs: 1, sm: 2, md: 3 },
width: { xs: '100%', md: '50%' },
flexDirection: { xs: 'column', md: 'row' },
}}
>
Responsive Layout
</Box>Pseudo-Selectors
<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)
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
<Grid container spacing={3}>
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }}>
Responsive Column
</Grid>
</Grid>Nested Grids
<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
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
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)
// ❌ 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
// ❌ 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
// ✅ PREFERRED
<Box
sx={{
p: 2,
backgroundColor: 'primary.main',
}}
>
Content
</Box>---
Code Style Standards
Indentation
4 spaces (not 2, not tabs)
const styles: Record<string, SxProps<Theme>> = {
container: {
p: 2,
display: 'flex',
flexDirection: 'column',
},
};Quotes
Single quotes for strings (project standard)
// ✅ 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
// ✅ 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
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
// 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 // = 4pxPositioning
const styles = {
relative: {
position: 'relative',
},
absolute: {
position: 'absolute',
top: 0,
right: 0,
},
sticky: {
position: 'sticky',
top: 0,
zIndex: 1000,
},
};---
Summary
Styling Checklist:
- ✅ Use
sxprop 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 structure
- complete-examples.md - Full styling examples
Related skills
Forks & variants (3)
Frontend Dev Guidelines has 3 known copies in the catalog totaling 384 installs. They canonicalize to this original listing.
- mrgoonie - 343 installs
- jackspace - 37 installs
- aia-11-hn-mib - 4 installs
FAQ
Which libraries does frontend-dev-guidelines cover?
frontend-dev-guidelines covers React, TypeScript, MUI (Material UI), and React Hook Form patterns. The readme includes useAuth, DataGrid, dialogs, and form examples with TypeScript React.FC components.
What auth rule does frontend-dev-guidelines enforce?
frontend-dev-guidelines documents useAuth for current user properties and explicitly states to never make direct API calls for authentication. Components should read user id, email, username, and roles through the hook.
Is Frontend Dev Guidelines safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.