For full-stack projects and data dashboards, TypeScript with React is my go-to combination. After years of building everything from internal analytics tools to customer-facing applications, I’ve found this stack delivers the best balance of developer experience, type safety, and production reliability. In this post, I’ll explain why.
Why TypeScript? The Pain Points It Solves
Remember this JavaScript scenario:
// ❌ JavaScript: Runtime surprises
function processOrder(order) {
return order.items.map(item => {
return item.price * item.quantity * (1 - order.discount);
}).reduce((sum, item) => sum + item, 0);
}
// Works fine... until it doesn't
processOrder({ items: [{ price: 10, quantity: 2 }], discount: 0.1 }); // ✓
processOrder({ items: [{ price: 10 }], discount: 0.1 }); // undefined * quantity = NaN
processOrder(null); // TypeError: Cannot read property 'items'
With TypeScript:
// ✅ TypeScript: Compile-time errors
interface OrderItem {
price: number;
quantity: number;
}
interface Order {
items: OrderItem[];
discount: number;
}
function processOrder(order: Order): number {
return order.items.map(item => {
return item.price * item.quantity * (1 - order.discount);
}).reduce((sum, item) => sum + item, 0);
}
// Caught at compile time!
processOrder({ items: [{ price: 10 }], discount: 0.1 }); // Error: missing 'quantity'
processOrder(null); // Error: Type 'null' is not assignable to type 'Order'
The Tangible Benefits
| Benefit | JavaScript | TypeScript |
|---|---|---|
| Error detection | Runtime (production) | Compile-time (development) |
| IDE support | Basic autocomplete | Full type inference |
| Refactoring | Manual, error-prone | Safe, automated |
| Documentation | Comments (often outdated) | Types (always current) |
| Onboarding | Learn by trial and error | IntelliSense guides you |
React + TypeScript: Better Together
Type-Safe Props
// ❌ JavaScript: Props are a mystery
function UserProfile(props) {
return (
<div>
<h1>{props.user.name}</h1>
<p>{props.user.email}</p>
{props.showAge && <p>Age: {props.user.age}</p>}
</div>
);
}
// What props does this need? You'll only know at runtime.
// ✅ TypeScript: Props are self-documenting
interface User {
id: string;
name: string;
email: string;
age?: number; // Optional
}
interface UserProfileProps {
user: User;
showAge?: boolean; // Optional with default behavior
onLogout?: () => void; // Callback
}
const UserProfile: React.FC<UserProfileProps> = ({
user,
showAge = false,
onLogout
}) => {
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
{showAge && user.age && <p>Age: {user.age}</p>}
{onLogout && <button onClick={onLogout}>Logout</button>}
</div>
);
};
// IDE shows exactly what's needed
<UserProfile
user={{...}} // ← Autocomplete shows required fields
showAge={true} // ← Optional props shown
onLogout={() => {}} // ← Callback signature shown
/>;
Type-Safe State
interface DashboardState {
data: Metrics[];
isLoading: boolean;
error: string | null;
lastUpdated: Date | null;
}
// TypeScript catches state mistakes
const [state, setState] = useState<DashboardState>({
data: [],
isLoading: false,
error: null,
lastUpdated: null
});
// Error: Type 'string' is not assignable to type 'Date | null'
setState({ ...state, lastUpdated: 'today' });
// Correct
setState({ ...state, lastUpdated: new Date() });
Common Patterns I Use
1. Custom Hooks with Types
// Generic fetch hook
interface UseFetchResult<T> {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
function useFetch<T>(
url: string,
options?: RequestInit
): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchData = useCallback(async () => {
try {
setLoading(true);
const response = await fetch(url, options);
if (!response.ok) throw new Error(response.statusText);
const result = await response.json();
setData(result);
} catch (e) {
setError(e as Error);
} finally {
setLoading(false);
}
}, [url, options]);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
// Usage with full type inference
interface UserData {
id: string;
name: string;
email: string;
}
function UserList() {
const { data, loading, error } = useFetch<UserData[]>('/api/users');
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!data) return null;
return (
<ul>
{data.map(user => (
<li key={user.id}>{user.name} ({user.email})</li>
))}
</ul>
);
}
2. Generic Components
// Generic list component
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string;
emptyMessage?: string;
}
function List<T>({
items,
renderItem,
keyExtractor,
emptyMessage = 'No items'
}: ListProps<T>) {
if (items.length === 0) {
return <p>{emptyMessage}</p>;
}
return (
<ul>
{items.map(item => (
<li key={keyExtractor(item)}>
{renderItem(item)}
</li>
))}
</ul>
);
}
// Usage
interface Product {
id: number;
name: string;
price: number;
}
<List<Product>
items={products}
keyExtractor={(item) => item.id.toString()}
renderItem={(item) => (
<div>
<h3>{item.name}</h3>
<p>${item.price.toFixed(2)}</p>
</div>
)}
/>;
3. Discriminated Unions for State Machines
// Type-safe state machines
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading'; progress?: number }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function DataFetcher<T>({ url }: { url: string }) {
const [state, setState] = useState<AsyncState<T>>({ status: 'idle' });
// TypeScript enforces correct state handling
switch (state.status) {
case 'idle':
return <button onClick={() => setState({ status: 'loading' })}>
Fetch Data
</button>;
case 'loading':
return (
<div>
<p>Loading...</p>
{state.progress && <progress value={state.progress} />}
</div>
);
case 'success':
// TypeScript knows 'data' exists here
return <pre>{JSON.stringify(state.data, null, 2)}</pre>;
case 'error':
// TypeScript knows 'error' exists here
return <div className="error">{state.error.message}</div>;
default:
// TypeScript will error if we miss a case
const exhaustiveCheck: never = state;
return exhaustiveCheck;
}
}
4. Event Handlers with Proper Types
interface FormState {
email: string;
password: string;
}
interface ValidationErrors {
email?: string;
password?: string;
}
function LoginForm() {
const [formState, setFormState] = useState<FormState>({
email: '',
password: ''
});
const [errors, setErrors] = useState<ValidationErrors>({});
// Typed event handler
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormState(prev => ({ ...prev, [name]: value }));
// Clear error when user types
setErrors(prev => ({ ...prev, [name]: undefined }));
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// Validate
const newErrors: ValidationErrors = {};
if (!formState.email.includes('@')) {
newErrors.email = 'Please enter a valid email';
}
if (formState.password.length < 8) {
newErrors.password = 'Password must be at least 8 characters';
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
// Submit form
console.log('Submitting:', formState);
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
name="email"
value={formState.email}
onChange={handleChange}
/>
{errors.email && <span className="error">{errors.email}</span>}
<input
type="password"
name="password"
value={formState.password}
onChange={handleChange}
/>
{errors.password && <span className="error">{errors.password}</span>}
<button type="submit">Sign In</button>
</form>
);
}
Real-World Example: Data Dashboard
Here’s a complete example from a real analytics dashboard I built:
// types/analytics.ts
export interface Metric {
id: string;
name: string;
value: number;
change: number;
trend: 'up' | 'down' | 'neutral';
}
export interface ChartDataPoint {
date: string;
value: number;
}
export interface DashboardFilters {
dateRange: { start: string; end: string };
segment?: string;
channel?: string;
}
// components/MetricCard.tsx
interface MetricCardProps {
metric: Metric;
onClick?: (metricId: string) => void;
selected?: boolean;
}
export const MetricCard: React.FC<MetricCardProps> = ({
metric,
onClick,
selected = false
}) => {
const trendIcon = metric.trend === 'up' ? '↑' : metric.trend === 'down' ? '↓' : '→';
const trendColor = metric.trend === 'up' ? 'text-green-600' :
metric.trend === 'down' ? 'text-red-600' : 'text-gray-600';
return (
<div
className={`metric-card ${selected ? 'selected' : ''}`}
onClick={() => onClick?.(metric.id)}
>
<h3>{metric.name}</h3>
<p className="value">{metric.value.toLocaleString()}</p>
<p className={`change ${trendColor}`}>
{trendIcon} {metric.change > 0 ? '+' : ''}{metric.change}%
</p>
</div>
);
};
// hooks/useAnalytics.ts
interface UseAnalyticsOptions {
autoRefetch?: boolean;
refetchInterval?: number;
}
export function useAnalytics(
filters: DashboardFilters,
options: UseAnalyticsOptions = {}
) {
const { autoRefetch = false, refetchInterval = 30000 } = options;
const fetchMetrics = useCallback(async (): Promise<Metric[]> => {
const response = await fetch('/api/analytics/metrics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(filters)
});
if (!response.ok) {
throw new Error('Failed to fetch metrics');
}
return response.json();
}, [filters]);
const { data, loading, error, refetch } = useFetch<Metric[]>(
'/api/analytics/metrics',
{
method: 'POST',
body: JSON.stringify(filters)
}
);
// Auto-refetch
useEffect(() => {
if (!autoRefetch) return;
const interval = setInterval(refetch, refetchInterval);
return () => clearInterval(interval);
}, [autoRefetch, refetchInterval, refetch]);
return { metrics: data, loading, error, refetch };
}
// pages/Dashboard.tsx
export const Dashboard: React.FC = () => {
const [filters, setFilters] = useState<DashboardFilters>({
dateRange: {
start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
end: new Date().toISOString()
}
});
const { metrics, loading, error, refetch } = useAnalytics(filters, {
autoRefetch: true,
refetchInterval: 60000 // Refetch every minute
});
const handleMetricClick = useCallback((metricId: string) => {
console.log('Selected metric:', metricId);
// Navigate to detail view, open modal, etc.
}, []);
if (error) {
return (
<div className="error-state">
<p>Failed to load dashboard data</p>
<button onClick={refetch}>Retry</button>
</div>
);
}
return (
<div className="dashboard">
<header>
<h1>Analytics Dashboard</h1>
<DateRangePicker
value={filters.dateRange}
onChange={(dateRange) => setFilters({ ...filters, dateRange })}
/>
</header>
{loading && !metrics ? (
<DashboardSkeleton />
) : (
<div className="metrics-grid">
{metrics?.map(metric => (
<MetricCard
key={metric.id}
metric={metric}
onClick={handleMetricClick}
/>
))}
</div>
)}
</div>
);
};
The Learning Curve: Worth It
Yes, TypeScript adds initial overhead. You’ll spend time:
- Defining types
- Fixing type errors
- Learning generic patterns
But this investment pays off:
| Phase | JavaScript | TypeScript |
|---|---|---|
| Development | Fast | Slightly slower |
| Debugging | Hours finding runtime bugs | Minutes fixing compile errors |
| Refactoring | Fear of breaking things | Confidence from type safety |
| Onboarding | Weeks to learn the codebase | Days with type hints |
| Production | Runtime errors | Type errors caught before deploy |
When TypeScript Shines
Perfect for:
- Large codebases with multiple developers
- Data-heavy applications (dashboards, analytics)
- Component libraries and design systems
- Long-term maintained projects
- APIs with strict contracts
Maybe overkill:
- Quick prototypes (though I still use it)
- Simple static sites
- One-off scripts
Getting Started
# Add TypeScript to React project
npm install typescript @types/react @types/react-dom
# For Create React App, it's built-in
npx create-react-app my-app --template typescript
# For Vite
npm create vite@latest my-app -- --template react-ts
Basic tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
Key Takeaways
TypeScript + React gives you:
- Type safety: Catch errors before production
- Better DX: Autocomplete, inline docs, safe refactoring
- Self-documenting code: Types describe intent
- Confidence: Refactor without fear
- Team productivity: Clear contracts between components
The initial learning curve pays dividends in maintainability and developer happiness.
Questions about TypeScript or React? Reach out through the contact page or connect on LinkedIn.
Related Posts
How I Built This Website for Under $3: AI-Powered Development with Claude Code
A complete guide to building a professional portfolio and blog website using AI tools, free hosting, and minimal budget. Includes exact prompts and step-by-step instructions to clone this project.
Data EngineeringPython Essentials for Data Engineering: Libraries, Patterns, and Best Practices
Master the Python libraries, design patterns, and performance techniques that every data engineer needs. Comprehensive guide with real-world examples for building robust data pipelines.