TypeScript

Type-Safe State Management

35 min Lesson 34 of 40

Type-Safe State Management

State management is crucial in modern applications, but untyped state can lead to runtime errors, unexpected mutations, and debugging nightmares. TypeScript transforms state management by providing compile-time guarantees about state shape, action types, and selector return values. In this lesson, we'll explore how to build type-safe state management with Redux, Zustand, and custom solutions that catch errors before they reach production.

Why Type Your State?

Type-safe state management provides critical benefits:

  • Compile-Time Safety: Catch state access errors and invalid actions before runtime
  • Autocomplete: Get IntelliSense for state properties, actions, and selectors
  • Refactoring: Rename state properties confidently with TypeScript's refactoring tools
  • Documentation: Types serve as living documentation of state shape and mutations
  • Predictability: Enforce valid state transitions and prevent invalid mutations

Typed Redux with Redux Toolkit

Redux Toolkit provides excellent TypeScript support out of the box. Here's how to leverage it:

Redux Store Setup:
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import userReducer from './slices/userSlice';
import postsReducer from './slices/postsSlice';
import uiReducer from './slices/uiSlice';

export const store = configureStore({
  reducer: {
    user: userReducer,
    posts: postsReducer,
    ui: uiReducer,
  },
});

// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Typed Hooks:
// store/hooks.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './index';

// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

Creating Typed Slices

Define strongly-typed Redux slices with state, actions, and reducers:

User Slice Example:
// store/slices/userSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';

// Define state interface
interface User {
  id: number;
  email: string;
  username: string;
  firstName: string;
  lastName: string;
  avatar?: string;
}

interface UserState {
  currentUser: User | null;
  isAuthenticated: boolean;
  loading: boolean;
  error: string | null;
}

// Initial state
const initialState: UserState = {
  currentUser: null,
  isAuthenticated: false,
  loading: false,
  error: null,
};

// Async thunk with typed return value
export const fetchUser = createAsyncThunk<
  User, // Return type
  number, // Argument type
  { rejectValue: string } // ThunkAPI config
>('user/fetchUser', async (userId, { rejectWithValue }) => {
  try {
    const response = await fetch(`/api/users/${userId}`);
    if (!response.ok) {
      return rejectWithValue('Failed to fetch user');
    }
    return await response.json();
  } catch (error) {
    return rejectWithValue('Network error');
  }
});

// Create slice with typed reducers
const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {
    // Typed action with payload
    setUser: (state, action: PayloadAction<User>) => {
      state.currentUser = action.payload;
      state.isAuthenticated = true;
    },
    // Action without payload
    logout: (state) => {
      state.currentUser = null;
      state.isAuthenticated = false;
    },
    // Typed partial update
    updateUser: (state, action: PayloadAction<Partial<User>>) => {
      if (state.currentUser) {
        state.currentUser = { ...state.currentUser, ...action.payload };
      }
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => {
        state.loading = true;
        state.error = null;
      })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.loading = false;
        state.currentUser = action.payload;
        state.isAuthenticated = true;
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.loading = false;
        state.error = action.payload ?? 'Unknown error';
      });
  },
});

export const { setUser, logout, updateUser } = userSlice.actions;
export default userSlice.reducer;
Note: Redux Toolkit's createSlice automatically generates action creators and action types from your reducers, with full TypeScript inference for action payloads.

Typed Selectors

Create reusable, typed selector functions for accessing state:

Selector Examples:
// store/slices/userSlice.ts (continued)
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from '../index';

// Basic selectors
export const selectCurrentUser = (state: RootState) => state.user.currentUser;
export const selectIsAuthenticated = (state: RootState) => state.user.isAuthenticated;
export const selectUserLoading = (state: RootState) => state.user.loading;
export const selectUserError = (state: RootState) => state.user.error;

// Memoized selector with createSelector
export const selectUserFullName = createSelector(
  [selectCurrentUser],
  (user) => user ? `${user.firstName} ${user.lastName}` : null
);

// Complex memoized selector
export const selectUserDisplayInfo = createSelector(
  [selectCurrentUser, selectIsAuthenticated],
  (user, isAuthenticated) => {
    if (!user || !isAuthenticated) return null;
    return {
      name: `${user.firstName} ${user.lastName}`,
      username: user.username,
      avatar: user.avatar ?? '/default-avatar.png',
    };
  }
);

// Usage in components
import { useAppSelector } from '../../store/hooks';
import { selectUserFullName, selectIsAuthenticated } from '../../store/slices/userSlice';

function UserProfile() {
  const fullName = useAppSelector(selectUserFullName);
  const isAuthenticated = useAppSelector(selectIsAuthenticated);

  if (!isAuthenticated) return <div>Please log in</div>;
  return <div>Welcome, {fullName}</div>;
}

Zustand - Lightweight Typed State

Zustand is a minimal state management library with excellent TypeScript support:

Basic Zustand Store:
// stores/userStore.ts
import { create } from 'zustand';

interface User {
  id: number;
  email: string;
  username: string;
}

interface UserState {
  currentUser: User | null;
  isAuthenticated: boolean;
  loading: boolean;
  // Actions
  setUser: (user: User) => void;
  logout: () => void;
  updateUser: (updates: Partial<User>) => void;
  fetchUser: (userId: number) => Promise<void>;
}

export const useUserStore = create<UserState>((set, get) => ({
  currentUser: null,
  isAuthenticated: false,
  loading: false,

  setUser: (user) =>
    set({
      currentUser: user,
      isAuthenticated: true,
    }),

  logout: () =>
    set({
      currentUser: null,
      isAuthenticated: false,
    }),

  updateUser: (updates) =>
    set((state) => ({
      currentUser: state.currentUser
        ? { ...state.currentUser, ...updates }
        : null,
    })),

  fetchUser: async (userId) => {
    set({ loading: true });
    try {
      const response = await fetch(`/api/users/${userId}`);
      const user = await response.json();
      set({ currentUser: user, isAuthenticated: true, loading: false });
    } catch (error) {
      set({ loading: false });
      console.error('Failed to fetch user:', error);
    }
  },
}));
Using Zustand in Components:
// Usage with full type safety
import { useUserStore } from './stores/userStore';

function UserProfile() {
  // Select specific state slices with type inference
  const currentUser = useUserStore((state) => state.currentUser);
  const isAuthenticated = useUserStore((state) => state.isAuthenticated);
  const updateUser = useUserStore((state) => state.updateUser);

  if (!isAuthenticated || !currentUser) {
    return <div>Please log in</div>;
  }

  return (
    <div>
      <h1>{currentUser.username}</h1>
      <button onClick={() => updateUser({ username: 'newname' })}>
        Update Username
      </button>
    </div>
  );
}
Tip: Zustand's selector syntax automatically infers types from your store definition. The selector function receives a fully-typed state parameter, providing excellent autocomplete.

Zustand with Middleware

Add TypeScript-friendly middleware for persistence, devtools, and more:

Middleware Example:
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { devtools } from 'zustand/middleware';

interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}

export const useCounterStore = create<CounterState>()(
  devtools(
    persist(
      (set) => ({
        count: 0,
        increment: () => set((state) => ({ count: state.count + 1 })),
        decrement: () => set((state) => ({ count: state.count - 1 })),
        reset: () => set({ count: 0 }),
      }),
      {
        name: 'counter-storage',
        storage: createJSONStorage(() => localStorage),
      }
    )
  )
);

Custom Type-Safe State Manager

Build a lightweight custom state manager with full TypeScript support:

Custom Store Implementation:
type Listener<T> = (state: T) => void;

class TypedStore<T> {
  private state: T;
  private listeners: Set<Listener<T>> = new Set();

  constructor(initialState: T) {
    this.state = initialState;
  }

  getState(): T {
    return this.state;
  }

  setState(updater: Partial<T> | ((state: T) => Partial<T>)): void {
    const updates = typeof updater === 'function' ? updater(this.state) : updater;
    this.state = { ...this.state, ...updates };
    this.notifyListeners();
  }

  subscribe(listener: Listener<T>): () => void {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  }

  private notifyListeners(): void {
    this.listeners.forEach((listener) => listener(this.state));
  }
}

// Usage
interface AppState {
  count: number;
  user: { name: string; email: string } | null;
}

const store = new TypedStore<AppState>({
  count: 0,
  user: null,
});

// Subscribe to changes
const unsubscribe = store.subscribe((state) => {
  console.log('State changed:', state);
});

// Update state with type safety
store.setState({ count: 1 }); // Valid
store.setState((state) => ({ count: state.count + 1 })); // Valid
// store.setState({ invalid: true }); // TypeScript error

Typed Action Patterns

Define discriminated union types for type-safe actions:

Action Union Types:
// Define all possible actions
type Action =
  | { type: 'SET_USER'; payload: User }
  | { type: 'LOGOUT' }
  | { type: 'UPDATE_USER'; payload: Partial<User> }
  | { type: 'SET_LOADING'; payload: boolean }
  | { type: 'SET_ERROR'; payload: string };

// Action creators with proper typing
const actions = {
  setUser: (user: User): Action => ({
    type: 'SET_USER',
    payload: user,
  }),
  logout: (): Action => ({
    type: 'LOGOUT',
  }),
  updateUser: (updates: Partial<User>): Action => ({
    type: 'UPDATE_USER',
    payload: updates,
  }),
  setLoading: (loading: boolean): Action => ({
    type: 'SET_LOADING',
    payload: loading,
  }),
  setError: (error: string): Action => ({
    type: 'SET_ERROR',
    payload: error,
  }),
};

// Reducer with exhaustive type checking
function reducer(state: UserState, action: Action): UserState {
  switch (action.type) {
    case 'SET_USER':
      return {
        ...state,
        currentUser: action.payload,
        isAuthenticated: true,
      };
    case 'LOGOUT':
      return {
        ...state,
        currentUser: null,
        isAuthenticated: false,
      };
    case 'UPDATE_USER':
      return {
        ...state,
        currentUser: state.currentUser
          ? { ...state.currentUser, ...action.payload }
          : null,
      };
    case 'SET_LOADING':
      return {
        ...state,
        loading: action.payload,
      };
    case 'SET_ERROR':
      return {
        ...state,
        error: action.payload,
      };
    default:
      // Exhaustiveness check: TypeScript error if any case is missing
      const exhaustiveCheck: never = action;
      return state;
  }
}
Warning: Always include the default: never exhaustiveness check in your reducer switch statements. This ensures TypeScript will error if you add a new action type but forget to handle it in the reducer.

Derived State with Selectors

Create computed values from state with memoization:

Advanced Selectors:
import { createSelector } from 'reselect'; // or from @reduxjs/toolkit

// Base selectors
const selectPosts = (state: RootState) => state.posts.items;
const selectPostsLoading = (state: RootState) => state.posts.loading;
const selectCurrentUserId = (state: RootState) => state.user.currentUser?.id;

// Derived selectors with memoization
export const selectPublishedPosts = createSelector(
  [selectPosts],
  (posts) => posts.filter((post) => post.published)
);

export const selectCurrentUserPosts = createSelector(
  [selectPosts, selectCurrentUserId],
  (posts, userId) => {
    if (!userId) return [];
    return posts.filter((post) => post.authorId === userId);
  }
);

export const selectPostsByTag = createSelector(
  [selectPosts, (_state: RootState, tag: string) => tag],
  (posts, tag) => posts.filter((post) => post.tags.includes(tag))
);

// Selector with complex computation
export const selectPostsStatistics = createSelector(
  [selectPosts],
  (posts) => ({
    total: posts.length,
    published: posts.filter((p) => p.published).length,
    draft: posts.filter((p) => !p.published).length,
    totalViews: posts.reduce((sum, post) => sum + post.viewCount, 0),
    averageViews: posts.length > 0
      ? posts.reduce((sum, post) => sum + post.viewCount, 0) / posts.length
      : 0,
  })
);
Exercise:
  1. Create a Redux Toolkit slice for managing a shopping cart with typed state (items, quantities, prices)
  2. Implement typed actions: addItem, removeItem, updateQuantity, clearCart
  3. Create an async thunk for applying discount codes with proper typing
  4. Build memoized selectors for: total price, item count, applied discounts
  5. Create a Zustand store for theme management with typed state and actions
  6. Implement middleware for persisting the theme to localStorage with TypeScript

Summary

In this lesson, you learned how to build type-safe state management with Redux Toolkit and Zustand. You explored typed slices with actions and reducers, created strongly-typed async thunks, built memoized selectors for derived state, and implemented custom type-safe state managers. You also learned how to use discriminated unions for exhaustive action handling and how to leverage TypeScript's type inference for cleaner, more maintainable state management code. These patterns ensure your state management is predictable, refactorable, and error-free at compile time.