Type-Safe State Management
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:
// 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;
// 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:
// 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;
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:
// 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:
// 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);
}
},
}));
// 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>
);
}
Zustand with Middleware
Add TypeScript-friendly middleware for persistence, devtools, and more:
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:
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:
// 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;
}
}
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:
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,
})
);
- Create a Redux Toolkit slice for managing a shopping cart with typed state (items, quantities, prices)
- Implement typed actions: addItem, removeItem, updateQuantity, clearCart
- Create an async thunk for applying discount codes with proper typing
- Build memoized selectors for: total price, item count, applied discounts
- Create a Zustand store for theme management with typed state and actions
- 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.