I've shipped bugs where the UI showed a loading spinner AND an error message simultaneously. I've built forms that let users submit while validation was still running. I've created dashboards that displayed "No data" next to a table full of data.
These weren't logic errors. They were state design errors.
Every time I fixed one of these bugs, I added more defensive checks: "If loading, don't show error. Unless there's cached data. But only if the user hasn't clicked retry..." The code became a maze of conditionals, and I still shipped bugs.
Then I learned a principle that changed everything: Make invalid states unrepresentable.
If your type system makes it impossible to create an invalid state, you can't ship a bug related to that state. Not "unlikely"—impossible.
Let me show you how.
The Problem: Boolean Soup
Here's the most common mistake in React state management:
interface DataState {
isLoading: boolean;
isError: boolean;
data: User[] | null;
error: string | null;
}
function UserList() {
const [state, setState] = useState<DataState>({
isLoading: false,
isError: false,
data: null,
error: null
});
// Later in the component...
if (state.isLoading) {
return <LoadingSpinner />;
}
if (state.isError) {
return <ErrorMessage message={state.error} />;
}
return <UserTable data={state.data} />;
}
Looks reasonable, right? But this design allows 16 different states, and most of them are invalid:
// Valid states (4):
{ isLoading: true, isError: false, data: null, error: null }
{ isLoading: false, isError: false, data: [...], error: null }
{ isLoading: false, isError: true, data: null, error: "..." }
{ isLoading: false, isError: false, data: null, error: null }
// Invalid states (12):
{ isLoading: true, isError: true, data: null, error: "..." } // Loading AND error?
{ isLoading: false, isError: false, data: [...], error: "..." } // Data AND error?
{ isLoading: false, isError: true, data: null, error: null } // Error but no message?
// ... 9 more invalid combinations
TypeScript doesn't prevent you from creating these invalid states. Your render logic has to defensively handle all 16 combinations, even though 12 should never exist.
This is the root cause of UI bugs.
The Solution: Discriminated Unions
Replace boolean soup with a single discriminated union that represents only valid states:
type DataState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
function UserList() {
const [state, setState] = useState<DataState<User[]>>({ status: 'idle' });
// TypeScript forces exhaustive handling
switch (state.status) {
case 'idle':
return <button onClick={loadUsers}>Load Users</button>;
case 'loading':
return <LoadingSpinner />;
case 'success':
// TypeScript KNOWS data exists here
return <UserTable data={state.data} />;
case 'error':
// TypeScript KNOWS error exists here
return <ErrorMessage message={state.error} />;
}
}
Now there are exactly 4 states, and all 4 are valid. Invalid states are literally impossible to create:
// ❌ TypeScript error: Can't have data without status: 'success'
setState({ status: 'loading', data: users });
// ❌ TypeScript error: Can't have error without status: 'error'
setState({ status: 'success', error: 'Failed' });
// ✅ Only valid combinations compile
setState({ status: 'loading' });
setState({ status: 'success', data: users });
setState({ status: 'error', error: 'Failed to load' });
Pattern 1: Async Data Fetching
Let's implement a complete data fetching component with proper state management.
The Old Way (Buggy)
function UserProfile({ userId }: { userId: string }) {
const [loading, setLoading] = useState(false);
const [user, setUser] = useState<User | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
setError(null); // Don't forget this!
fetchUser(userId)
.then(data => {
setUser(data);
setLoading(false); // Don't forget this!
})
.catch(err => {
setError(err.message);
setLoading(false); // Don't forget this!
setUser(null); // Don't forget this!
});
}, [userId]);
// Easy to forget to check all conditions
if (loading) return <Spinner />;
if (error) return <Error message={error} />;
if (!user) return <div>No data</div>; // Is this even reachable?
return <div>{user.name}</div>;
}
Problems:
- Three separate setState calls that must be coordinated
- Easy to forget to reset states (loading, error)
- Impossible to know if render logic is correct
- Race conditions if userId changes during fetch
The Type-Safe Way
type AsyncData<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
function UserProfile({ userId }: { userId: string }) {
const [state, setState] = useState<AsyncData<User>>({ status: 'idle' });
useEffect(() => {
setState({ status: 'loading' });
fetchUser(userId)
.then(data => setState({ status: 'success', data }))
.catch(err => setState({ status: 'error', error: err.message }));
}, [userId]);
switch (state.status) {
case 'idle':
return <button onClick={() => setState({ status: 'loading' })}>Load Profile</button>;
case 'loading':
return <Spinner />;
case 'success':
return <div>{state.data.name}</div>;
case 'error':
return <Error message={state.error} />;
}
}
Benefits:
- Single setState call = impossible to get into inconsistent state
- TypeScript enforces exhaustive switch
- No "impossible" branches to worry about
- Clear state transitions
Pattern 2: Forms with Validation
Form state is notorious for bugs. Let's fix it.
The Buggy Approach
interface FormState {
email: string;
password: string;
emailError: string | null;
passwordError: string | null;
isValidating: boolean;
isSubmitting: boolean;
submitError: string | null;
}
// Can you confidently answer:
// - Can we submit while validating?
// - Can we have submitError AND isSubmitting both true?
// - When should we show emailError?
The Type-Safe Approach
type FieldState<T> =
| { status: 'pristine' }
| { status: 'validating' }
| { status: 'valid'; value: T }
| { status: 'invalid'; value: T; errors: string[] };
type FormState = {
email: FieldState<string>;
password: FieldState<string>;
submission:
| { status: 'idle' }
| { status: 'submitting' }
| { status: 'success' }
| { status: 'error'; error: string };
};
function LoginForm() {
const [form, setForm] = useState<FormState>({
email: { status: 'pristine' },
password: { status: 'pristine' },
submission: { status: 'idle' }
});
// Can only submit if both fields are valid
const canSubmit =
form.email.status === 'valid' &&
form.password.status === 'valid' &&
form.submission.status === 'idle';
const handleSubmit = async () => {
if (!canSubmit) return; // TypeScript proves this is safe
// TypeScript KNOWS these values exist because status === 'valid'
const email = form.email.value;
const password = form.password.value;
setForm(prev => ({ ...prev, submission: { status: 'submitting' } }));
try {
await login(email, password);
setForm(prev => ({ ...prev, submission: { status: 'success' } }));
} catch (err) {
setForm(prev => ({
...prev,
submission: { status: 'error', error: err.message }
}));
}
};
return (
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<EmailField field={form.email} onChange={/* ... */} />
<PasswordField field={form.password} onChange={/* ... */} />
<button
type="submit"
disabled={!canSubmit || form.submission.status === 'submitting'}
>
{form.submission.status === 'submitting' ? 'Logging in...' : 'Login'}
</button>
{form.submission.status === 'error' && (
<ErrorMessage>{form.submission.error}</ErrorMessage>
)}
</form>
);
}
// Field components receive only valid states
function EmailField({
field,
onChange
}: {
field: FieldState<string>;
onChange: (value: string) => void;
}) {
return (
<div>
<input
type="email"
value={field.status === 'pristine' ? '' : field.value}
onChange={e => onChange(e.target.value)}
className={field.status === 'invalid' ? 'error' : ''}
/>
{field.status === 'invalid' && (
<div className="errors">
{field.errors.map(err => <p key={err}>{err}</p>)}
</div>
)}
{field.status === 'validating' && <Spinner size="small" />}
</div>
);
}
Now it's impossible to:
- Submit while fields are validating
- Submit with invalid fields
- Show validation errors on pristine fields
- Have submitError and isSubmitting true simultaneously
Pattern 3: Multi-Step Wizards
Multi-step forms are a minefield of state bugs. Let's make them type-safe.
The Problem
interface WizardState {
step: number;
personalInfo: PersonalInfo | null;
addressInfo: AddressInfo | null;
paymentInfo: PaymentInfo | null;
}
// Can we render step 3 if personalInfo is null?
// Can we go to step 2 without completing step 1?
// TypeScript doesn't know!
The Solution
type WizardState =
| { step: 'personal'; data: Partial<PersonalInfo> }
| { step: 'address'; personalInfo: PersonalInfo; data: Partial<AddressInfo> }
| { step: 'payment'; personalInfo: PersonalInfo; addressInfo: AddressInfo; data: Partial<PaymentInfo> }
| { step: 'review'; personalInfo: PersonalInfo; addressInfo: AddressInfo; paymentInfo: PaymentInfo }
| { step: 'submitting'; personalInfo: PersonalInfo; addressInfo: AddressInfo; paymentInfo: PaymentInfo }
| { step: 'complete'; confirmationId: string };
function CheckoutWizard() {
const [state, setState] = useState<WizardState>({
step: 'personal',
data: {}
});
switch (state.step) {
case 'personal':
return (
<PersonalInfoStep
data={state.data}
onNext={(personalInfo) =>
setState({
step: 'address',
personalInfo, // Required for next step
data: {}
})
}
/>
);
case 'address':
return (
<AddressStep
data={state.data}
onBack={() =>
setState({
step: 'personal',
data: state.personalInfo // Can go back safely
})
}
onNext={(addressInfo) =>
setState({
step: 'payment',
personalInfo: state.personalInfo, // TypeScript KNOWS this exists
addressInfo,
data: {}
})
}
/>
);
case 'payment':
return (
<PaymentStep
data={state.data}
onNext={(paymentInfo) =>
setState({
step: 'review',
personalInfo: state.personalInfo,
addressInfo: state.addressInfo,
paymentInfo
})
}
/>
);
case 'review':
return (
<ReviewStep
personalInfo={state.personalInfo}
addressInfo={state.addressInfo}
paymentInfo={state.paymentInfo}
onSubmit={() =>
setState({
step: 'submitting',
...state // All data is present
})
}
/>
);
case 'submitting':
// Submit and transition to complete
return <LoadingSpinner />;
case 'complete':
return <ConfirmationPage confirmationId={state.confirmationId} />;
}
}
TypeScript now enforces:
- Each step has the data it needs from previous steps
- Can't skip ahead without completing previous steps
- Can't submit without all required data
- Going back preserves completed data
Pattern 4: Authentication State
Authentication is critical. Let's make it bulletproof.
The Buggy Version
interface AuthState {
isAuthenticated: boolean;
user: User | null;
token: string | null;
isLoading: boolean;
}
// What does this mean?
// { isAuthenticated: true, user: null, token: null, isLoading: false }
// Or this?
// { isAuthenticated: false, user: {...}, token: "...", isLoading: false }
The Type-Safe Version
type AuthState =
| { status: 'initializing' }
| { status: 'authenticated'; user: User; token: string }
| { status: 'unauthenticated' }
| { status: 'error'; error: string };
function useAuth() {
const [auth, setAuth] = useState<AuthState>({ status: 'initializing' });
useEffect(() => {
const token = localStorage.getItem('token');
if (!token) {
setAuth({ status: 'unauthenticated' });
return;
}
fetchUser(token)
.then(user => setAuth({ status: 'authenticated', user, token }))
.catch(() => {
localStorage.removeItem('token');
setAuth({ status: 'unauthenticated' });
});
}, []);
const login = async (credentials: Credentials) => {
try {
const { user, token } = await loginAPI(credentials);
localStorage.setItem('token', token);
setAuth({ status: 'authenticated', user, token });
} catch (err) {
setAuth({ status: 'error', error: err.message });
}
};
const logout = () => {
localStorage.removeItem('token');
setAuth({ status: 'unauthenticated' });
};
return { auth, login, logout };
}
function App() {
const { auth, login, logout } = useAuth();
switch (auth.status) {
case 'initializing':
return <SplashScreen />;
case 'authenticated':
// TypeScript KNOWS user and token exist
return <Dashboard user={auth.user} onLogout={logout} />;
case 'unauthenticated':
return <LoginPage onLogin={login} />;
case 'error':
return <ErrorPage error={auth.error} />;
}
}
Now it's impossible to:
- Render authenticated UI without a user
- Have a token without a user (or vice versa)
- Be in authenticated and unauthenticated state simultaneously
Pattern 5: Feature Flags
Feature flags often lead to bugs. Let's fix that.
The Problem
interface FeatureFlags {
newDashboard: boolean;
advancedSearch: boolean;
betaFeatures: boolean;
}
// What if newDashboard requires advancedSearch?
// What if betaFeatures includes features with their own requirements?
The Solution
type FeatureState =
| { feature: 'new-dashboard'; enabled: false }
| { feature: 'new-dashboard'; enabled: true; config: { theme: 'light' | 'dark' } }
| { feature: 'advanced-search'; enabled: false }
| { feature: 'advanced-search'; enabled: true; config: { maxResults: number } };
type Features = {
'new-dashboard': Extract<FeatureState, { feature: 'new-dashboard' }>;
'advanced-search': Extract<FeatureState, { feature: 'advanced-search' }>;
};
function useFeature<K extends keyof Features>(
featureName: K
): Features[K] {
// Fetch from API or config
const features: Features = {
'new-dashboard': { feature: 'new-dashboard', enabled: true, config: { theme: 'dark' } },
'advanced-search': { feature: 'advanced-search', enabled: false }
};
return features[featureName];
}
function Dashboard() {
const dashboardFeature = useFeature('new-dashboard');
if (!dashboardFeature.enabled) {
return <LegacyDashboard />;
}
// TypeScript KNOWS config exists when enabled: true
return <NewDashboard theme={dashboardFeature.config.theme} />;
}
Pattern 6: Modal State
Modals are simple but often implemented poorly.
The Problem
interface ModalState {
isOpen: boolean;
data: UserData | null;
}
// If isOpen is true but data is null, what do we render?
The Solution
type ModalState =
| { isOpen: false }
| { isOpen: true; data: UserData };
function UserListWithModal() {
const [modal, setModal] = useState<ModalState>({ isOpen: false });
const openModal = (user: UserData) => {
setModal({ isOpen: true, data: user });
};
const closeModal = () => {
setModal({ isOpen: false });
};
return (
<>
<UserList onUserClick={openModal} />
{modal.isOpen && (
<Modal onClose={closeModal}>
{/* TypeScript KNOWS modal.data exists */}
<UserDetails user={modal.data} />
</Modal>
)}
</>
);
}
Pattern 7: List Operations (Selection, Filtering, Sorting)
Complex list state is a common source of bugs.
The Type-Safe Approach
type SelectionMode =
| { mode: 'none' }
| { mode: 'single'; selectedId: string }
| { mode: 'multiple'; selectedIds: Set<string> };
type SortConfig<T> = {
key: keyof T;
direction: 'asc' | 'desc';
};
type ListState<T> = {
items: T[];
filter: string;
sort: SortConfig<T> | null;
selection: SelectionMode;
};
function UserList() {
const [state, setState] = useState<ListState<User>>({
items: [],
filter: '',
sort: null,
selection: { mode: 'none' }
});
const enableMultiSelect = () => {
setState(prev => ({
...prev,
selection: { mode: 'multiple', selectedIds: new Set() }
}));
};
const toggleSelection = (userId: string) => {
if (state.selection.mode === 'multiple') {
const newIds = new Set(state.selection.selectedIds);
if (newIds.has(userId)) {
newIds.delete(userId);
} else {
newIds.add(userId);
}
setState(prev => ({
...prev,
selection: { mode: 'multiple', selectedIds: newIds }
}));
}
};
const deleteSelected = () => {
if (state.selection.mode !== 'multiple') return;
// TypeScript KNOWS selectedIds exists
const idsToDelete = state.selection.selectedIds;
// Delete logic...
};
return (
<div>
{state.selection.mode === 'multiple' && (
<button onClick={deleteSelected}>
Delete {state.selection.selectedIds.size} items
</button>
)}
{/* List rendering */}
</div>
);
}
Pattern 8: Stale Data with Refetch
Handling stale data while refetching is tricky.
type DataState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T; isRefetching: false }
| { status: 'success'; data: T; isRefetching: true }
| { status: 'error'; error: string };
function UserList() {
const [state, setState] = useState<DataState<User[]>>({ status: 'idle' });
const fetchUsers = async () => {
setState({ status: 'loading' });
try {
const data = await api.fetchUsers();
setState({ status: 'success', data, isRefetching: false });
} catch (err) {
setState({ status: 'error', error: err.message });
}
};
const refetchUsers = async () => {
if (state.status !== 'success') return;
// Keep showing old data while refetching
setState({ ...state, isRefetching: true });
try {
const data = await api.fetchUsers();
setState({ status: 'success', data, isRefetching: false });
} catch (err) {
// Keep old data, show error toast
setState({ ...state, isRefetching: false });
showToast(err.message);
}
};
switch (state.status) {
case 'idle':
return <button onClick={fetchUsers}>Load</button>;
case 'loading':
return <Spinner />;
case 'success':
return (
<div>
<button onClick={refetchUsers} disabled={state.isRefetching}>
{state.isRefetching ? 'Refreshing...' : 'Refresh'}
</button>
<UserTable data={state.data} opacity={state.isRefetching ? 0.5 : 1} />
</div>
);
case 'error':
return <Error message={state.error} onRetry={fetchUsers} />;
}
}
The Refactoring Test
Here's how you know your state design is good: Change a type definition and let TypeScript guide you.
Bad Design
// Change this:
interface UserState {
user: User | null;
loading: boolean;
}
// To this:
interface UserState {
user: User | null;
loading: boolean;
error: string | null; // Added error
}
// Result: No TypeScript errors! But your UI is probably broken.
Good Design
// Change this:
type UserState =
| { status: 'loading' }
| { status: 'success'; user: User }
| { status: 'error'; error: string };
// To this:
type UserState =
| { status: 'loading' }
| { status: 'success'; user: User }
| { status: 'error'; error: string }
| { status: 'retry-error'; error: string; lastData: User }; // Added retry state
// Result: TypeScript errors at EVERY place that handles UserState
// You're forced to handle the new state explicitly
The type system becomes your refactoring assistant.
Practical Tips
1. Start with States, Not Properties
Don't ask: "What properties does my component need?"
Ask: "What states can my component be in?"
List all possible states, then model them.
2. Use the Compiler as a TODO List
After adding a new state to your union, compile. Every TypeScript error is a place you need to handle the new state.
3. Prefer Specific Over Generic
// ❌ Generic but not helpful
type State = 'idle' | 'loading' | 'success' | 'error';
// ✅ Specific and type-safe
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; error: string };
4. Co-locate Transitions
Keep state transitions close to state definitions:
type AsyncData<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
const AsyncData = {
idle: (): AsyncData<never> => ({ status: 'idle' }),
loading: (): AsyncData<never> => ({ status: 'loading' }),
success: <T>(data: T): AsyncData<T> => ({ status: 'success', data }),
error: (error: string): AsyncData<never> => ({ status: 'error', error })
};
// Usage
setState(AsyncData.loading());
setState(AsyncData.success(users));
When to Use This Pattern
Use discriminated unions when:
- Multiple pieces of state are interdependent
- Invalid combinations are possible with independent state
- You find yourself writing defensive checks like "if (x && y && !z)"
- State transitions have complex rules
Don't use when:
- States are truly independent (e.g., theme preference and user data)
- The added complexity doesn't prevent bugs
- Simple booleans are sufficient and clear
The Payoff
Type-safe state design isn't about being clever with types. It's about eliminating entire categories of bugs:
✅ No more "loading spinner with error message" bugs
✅ No more "submit button works with invalid data" bugs
✅ No more "displaying stale data as fresh" bugs
✅ No more "step 3 without completing step 2" bugs
These bugs are impossible. Not rare—impossible.
And when you refactor? TypeScript guides you through every place that needs updating. No more "I think I got them all" deployments.
Conclusion
The best code is code that makes bugs impossible to write.
Boolean soup creates 2^n states, most of them invalid. Your render logic becomes defensive spaghetti.
Discriminated unions create n states, all of them valid. Your render logic becomes straightforward pattern matching.
The TypeScript compiler becomes your safety net. Invalid states don't compile. State transitions are explicit. Refactoring is guided.
Start small. Pick one component with buggy state. Model it as a discriminated union. Watch the bugs disappear.
Then scale it up. Your future self—and your users—will thank you.
What invalid states have caused bugs in your apps? Share your war stories in the comments.
Top comments (0)