DEV Community

Tarun Moorjani
Tarun Moorjani

Posted on

Type-Safe React: Making Invalid UI States Impossible

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} />;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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} />;
  }
}
Enter fullscreen mode Exit fullscreen mode

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' });
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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} />;
  }
}
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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} />;
  }
}
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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} />;
  }
}
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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} />;
}
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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>
      )}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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} />;
  }
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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)