Expert guidance for building React applications with TypeScript, covering component architecture, state management, hooks, and best practices learned from Bill Tracker
This skill provides expert guidance for building modern React applications with TypeScript, based on patterns and best practices from the Bill Tracker project.
Functional Components Only
Component Organization:
src/
├── components/ # Reusable UI components
├── pages/ # Page-level components (routes)
├── context/ # React Context providers
├── hooks/ # Custom hooks
└── utils/ # Utility functions
Strict Mode
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true
}
}
Type Definitions:
src/types/index.tsany - use unknown if type is truly unknownExample from Bill Tracker:
export interface Bill extends Entry {
type: 'bill';
paid: boolean;
amounts: Partial<Record<string, number>>;
}
export interface BillTemplate {
id: string;
name: string;
recurrence: RecurrenceType | 'one-time';
day: number;
amounts: Partial<Record<string, number>>;
autoGenerate: boolean;
isActive: boolean;
endMonth?: string;
}
React Context for Global State:
// Pattern from DataContext.tsx
interface DataContextType {
entries: Entry[];
templates: BillTemplate[];
addEntry: (entry: Entry) => void;
updateEntry: (entry: Entry) => void;
deleteEntry: (id: string) => void;
}
export const DataContext = createContext<DataContextType | undefined>(undefined);
export const useData = () => {
const context = useContext(DataContext);
if (!context) {
throw new Error('useData must be used within DataProvider');
}
return context;
};
Local State with useState:
Example:
const [setupComplete, setSetupComplete] = useState(() => {
return localStorage.getItem('setupComplete') === 'true';
});
Extract Reusable Logic:
// hooks/useCalculations.ts
export const useCalculations = (entries: Entry[], accounts: Account[]) => {
return useMemo(() => {
// Complex calculation logic
return calculatedData;
}, [entries, accounts]);
};
Hook Naming: Always prefix with use
Hook Rules:
useMemo for Expensive Calculations:
const sortedEntries = useMemo(() => {
return entries.sort((a, b) => a.date.localeCompare(b.date));
}, [entries]);
useCallback for Function Props:
const handleSubmit = useCallback((data: FormData) => {
// Handle submission
}, [dependencies]);
React.memo for Component Optimization:
export const ExpensiveComponent = React.memo(({ data }: Props) => {
// Component logic
});
Controlled Components:
const [name, setName] = useState('');
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="..."
/>
Form Submission:
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Validation
if (!name.trim()) return;
// Submit logic
onSave({ name, ...otherData });
};
Prefer && for Simple Conditions:
{isOpen && <Modal />}
Use Ternary for If/Else:
{isLoading ? <Spinner /> : <Content />}
Early Returns for Complex Logic:
if (!data) return <EmptyState />;
if (error) return <ErrorState />;
return <SuccessState data={data} />;
Type Event Handlers:
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
// Logic
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
};
Define Props Interface:
interface BillFormProps {
initialData?: Bill;
onSave: (bill: Bill) => void;
onClose: () => void;
}
export const BillForm = ({ initialData, onSave, onClose }: BillFormProps) => {
// Component logic
};
Optional Props with Defaults:
interface Props {
title?: string;
count?: number;
}
export const Component = ({ title = 'Default', count = 0 }: Props) => {
// Use title and count
};
Create Error Boundary Component:
class ErrorBoundary extends React.Component<Props, State> {
state = { hasError: false };
static getDerivedStateFromError(error: Error) {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Error caught:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return <ErrorFallback />;
}
return this.props.children;
}
}
const [isOpen, setIsOpen] = useState(false);
{isOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center">
<div className="bg-white rounded-lg p-6">
{/* Modal content */}
<button onClick={() => setIsOpen(false)}>Close</button>
</div>
</div>
)}
{items.map(item => (
<div key={item.id}>
{item.name}
</div>
))}
<div className={`base-class ${isActive ? 'active' : 'inactive'}`}>
const [data, setData] = useState<Data[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch('/api/data');
const json = await response.json();
setData(json);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
❌ Don't mutate state directly
// Bad
state.push(item);
// Good
setState([...state, item]);
❌ Don't use index as key
// Bad
{items.map((item, index) => <div key={index}>...)}
// Good
{items.map(item => <div key={item.id}>...)}
❌ Don't forget dependencies in useEffect
// Bad
useEffect(() => {
doSomething(value);
}, []); // Missing dependency
// Good
useEffect(() => {
doSomething(value);
}, [value]);
❌ Don't use inline object/array literals in dependencies
// Bad - creates new object every render
useEffect(() => {
// ...
}, [{ id: 1 }]);
// Good
const config = useMemo(() => ({ id: 1 }), []);
useEffect(() => {
// ...
}, [config]);
import { render, screen, fireEvent } from '@testing-library/react';
import { BillForm } from './BillForm';
describe('BillForm', () => {
it('should call onSave with form data', () => {
const onSave = vi.fn();
render(<BillForm onSave={onSave} onClose={() => {}} />);
fireEvent.change(screen.getByLabelText('Name'), {
target: { value: 'Test Bill' }
});
fireEvent.click(screen.getByText('Save'));
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ name: 'Test Bill' })
);
});
});
import { renderHook } from '@testing-library/react';
import { useCalculations } from './useCalculations';
describe('useCalculations', () => {
it('should calculate totals correctly', () => {
const { result } = renderHook(() =>
useCalculations(mockEntries, mockAccounts)
);
expect(result.current.total).toBe(100);
});
});