10.2 Props and State Types — useState<T> and Props Design
useState<T> Types
Basic Usage
import { useState } from 'react';
// Basic types — type inference works
const [count, setCount] = useState(0); // number
const [name, setName] = useState(''); // string
const [active, setActive] = useState(false); // boolean
// Explicit types (when initial value is null or complex)
const [user, setUser] = useState<User | null>(null);
const [items, setItems] = useState<string[]>([]);
const [data, setData] = useState<ApiResponse<User> | undefined>(undefined);
Complex State Types
interface UserProfile {
id: string;
name: string;
email: string;
avatar?: string;
preferences: {
theme: 'light' | 'dark';
language: string;
notifications: boolean;
};
}
function ProfileEditor() {
const [profile, setProfile] = useState<UserProfile>({
id: '',
name: '',
email: '',
preferences: {
theme: 'light',
language: 'en',
notifications: true,
},
});
// Nested state update
const updateTheme = (theme: 'light' | 'dark') => {
setProfile(prev => ({
...prev,
preferences: {
...prev.preferences,
theme,
},
}));
};
return (
<div>
<input
value={profile.name}
onChange={e => setProfile(prev => ({ ...prev, name: e.target.value }))}
/>
</div>
);
}
Complex State Management with useReducer
Use useReducer when state logic becomes complex.
import { useReducer } from 'react';
// State type
interface CartState {
items: CartItem[];
total: number;
isLoading: boolean;
}
// Action types (discriminated union)
type CartAction =
| { type: 'ADD_ITEM'; payload: CartItem }
| { type: 'REMOVE_ITEM'; payload: string } // id
| { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } }
| { type: 'CLEAR_CART' }
| { type: 'SET_LOADING'; payload: boolean };
// Reducer
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD_ITEM':
return {
...state,
items: [...state.items, action.payload],
total: state.total + action.payload.price,
};
case 'REMOVE_ITEM':
const item = state.items.find(i => i.id === action.payload);
return {
...state,
items: state.items.filter(i => i.id !== action.payload),
total: state.total - (item?.price ?? 0),
};
case 'CLEAR_CART':
return { ...state, items: [], total: 0 };
case 'SET_LOADING':
return { ...state, isLoading: action.payload };
default:
return state; // TypeScript exhaustiveness check
}
}
function Cart() {
const [state, dispatch] = useReducer(cartReducer, {
items: [],
total: 0,
isLoading: false,
});
return (
<div>
{state.items.map(item => (
<div key={item.id}>
{item.name}
<button onClick={() => dispatch({ type: 'REMOVE_ITEM', payload: item.id })}>
Remove
</button>
</div>
))}
<p>Total: ${state.total}</p>
</div>
);
}
Props Type Design Patterns
Default and Optional Props
interface SearchBarProps {
// Required props
onSearch: (query: string) => void;
// Optional props (with defaults)
placeholder?: string;
initialValue?: string;
debounceMs?: number;
disabled?: boolean;
}
function SearchBar({
onSearch,
placeholder = 'Enter search term...',
initialValue = '',
debounceMs = 300,
disabled = false,
}: SearchBarProps) {
const [query, setQuery] = useState(initialValue);
// Debounce
useEffect(() => {
const timer = setTimeout(() => onSearch(query), debounceMs);
return () => clearTimeout(timer);
}, [query, debounceMs, onSearch]);
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={placeholder}
disabled={disabled}
/>
);
}
PropsWithChildren
import { PropsWithChildren } from 'react';
// PropsWithChildren<P> = P & { children?: ReactNode }
type ModalProps = PropsWithChildren<{
isOpen: boolean;
onClose: () => void;
title: string;
}>;
function Modal({ isOpen, onClose, title, children }: ModalProps) {
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{title}</h2>
<button onClick={onClose}>✕</button>
</div>
<div className="modal-body">{children}</div>
</div>
</div>
);
}
Callback Props Types
interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
onRowClick?: (row: T, index: number) => void;
onSort?: (column: keyof T, direction: 'asc' | 'desc') => void;
onPageChange?: (page: number, pageSize: number) => void;
renderEmpty?: () => React.ReactNode;
}
interface Column<T> {
key: keyof T;
header: string;
width?: number;
render?: (value: T[keyof T], row: T) => React.ReactNode;
}
function DataTable<T extends { id: string | number }>({
data,
columns,
onRowClick,
}: DataTableProps<T>) {
return (
<table>
<thead>
<tr>
{columns.map(col => (
<th key={String(col.key)}>{col.header}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, index) => (
<tr key={row.id} onClick={() => onRowClick?.(row, index)}>
{columns.map(col => (
<td key={String(col.key)}>
{col.render
? col.render(row[col.key], row)
: String(row[col.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
Lifting State Up and Props Flow
// Parent: owns the state
interface ParentState {
selectedId: string | null;
items: Item[];
}
function Parent() {
const [state, setState] = useState<ParentState>({
selectedId: null,
items: [],
});
const handleSelect = (id: string) => {
setState(prev => ({ ...prev, selectedId: id }));
};
return (
<div>
<ItemList
items={state.items}
selectedId={state.selectedId}
onSelect={handleSelect}
/>
{state.selectedId && (
<ItemDetail id={state.selectedId} />
)}
</div>
);
}
// Child: receives via Props
interface ItemListProps {
items: Item[];
selectedId: string | null;
onSelect: (id: string) => void;
}
function ItemList({ items, selectedId, onSelect }: ItemListProps) {
return (
<ul>
{items.map(item => (
<li
key={item.id}
className={item.id === selectedId ? 'selected' : ''}
onClick={() => onSelect(item.id)}
>
{item.name}
</li>
))}
</ul>
);
}
Pro Tips
1. Simplify update functions with Partial<T>
interface Settings {
theme: 'light' | 'dark';
fontSize: number;
language: string;
}
function useSettings() {
const [settings, setSettings] = useState<Settings>({
theme: 'light',
fontSize: 14,
language: 'en',
});
// Update only some fields with Partial
const updateSettings = (updates: Partial<Settings>) => {
setSettings(prev => ({ ...prev, ...updates }));
};
return { settings, updateSettings };
}
// Usage
updateSettings({ theme: 'dark' }); // fontSize, language remain unchanged
2. Use Immer for immutable state updates
import { useImmer } from 'use-immer';
interface ComplexState {
users: User[];
selectedIds: Set<string>;
}
function Component() {
const [state, updateState] = useImmer<ComplexState>({
users: [],
selectedIds: new Set(),
});
const toggleUser = (id: string) => {
updateState(draft => {
if (draft.selectedIds.has(id)) {
draft.selectedIds.delete(id);
} else {
draft.selectedIds.add(id);
}
});
};
}