Why Use TypeScript with Redux Toolkit
State management is a critical aspect of building maintainable React applications, and TypeScript adds a layer of type safety that catches errors before they reach production. When combined, Redux Toolkit and TypeScript provide a powerful, type-safe state management solution that simplifies development while maintaining code quality.
Key benefits include:
- Compile-time error detection for state mutations
- Type-safe selectors and action creators
- Improved developer experience with autocomplete
- Safer refactoring across large codebases
- Self-documenting code through type annotations
Redux Toolkit is already written in TypeScript, and its API is designed to provide an excellent experience for TypeScript users. This means the library itself has been type-checked and its types have been carefully crafted to work well with TypeScript's type inference system. The combination of Redux Toolkit and TypeScript is now considered the standard approach for writing modern Redux applications, providing significant value that justifies the added overhead, especially in larger codebases.
For teams building complex React applications, implementing proper type safety across the codebase becomes essential for maintaining code quality as projects scale.
Everything you need to build robust React applications
Typed Store Configuration
Automatically infer RootState and AppDispatch types from your store configuration
Custom Hooks
Pre-typed useAppDispatch and useAppSelector hooks for component integration
Type-Safe Slices
createSlice with full PayloadAction typing for reducer functions
Async Operations
createAsyncThunk with proper generic type parameters
Selector Types
Type-safe selectors with inferred return types
Setting Up Your Store with Type Definitions
The foundation of any Redux application is the store, which holds the application state. With TypeScript, you'll extract the RootState type and AppDispatch type so they can be referenced throughout your application. The key insight is that inferring these types from the store itself means they correctly update as you add more state slices or modify middleware settings.
When you configure your store using configureStore, you don't need any additional typings for the configuration itself. The types are inferred automatically from the reducers and middleware you provide. However, you should export the types so other parts of your application can reference them safely.
import { configureStore } from '@reduxjs/toolkit';
import postsReducer from './features/posts/postsSlice';
import commentsReducer from './features/comments/commentsSlice';
import usersReducer from './features/users/usersSlice';
export const store = configureStore({
reducer: {
posts: postsReducer,
comments: commentsReducer,
users: usersReducer,
},
});
export type AppStore = typeof store;
export type RootState = ReturnType<AppStore['getState']>;
export type AppDispatch = AppStore['dispatch'];
Key points:
- Types are inferred from the store configuration
- RootState reflects your complete application state shape
- AppDispatch includes all middleware types (including thunks)
- Types update automatically when you modify the store
The AppDispatch type is particularly important because it includes the types of all middleware you've configured. By default, the Dispatch type from Redux core doesn't know about thunks or other middleware, so using the generic Dispatch type would lose the ability to dispatch thunks directly. Extracting AppDispatch from the store ensures that dispatching continues to work correctly with all your middleware.
Creating Typed Hooks for Your Application
While you could import RootState and AppDispatch types into each component, it's better practice to create pre-typed versions of the useDispatch and useSelector hooks. This approach provides several benefits that make your code cleaner and less error-prone.
For useSelector, the pre-typed hook saves you from having to type (state: RootState) every time. More importantly, for useDispatch, the default Dispatch type doesn't know about thunks or other middleware. Creating a typed useAppDispatch hook ensures that you can dispatch thunks without any type errors.
The modern approach uses the withTypes method added in React Redux v9.1.0:
import { useDispatch, useSelector, useStore } from 'react-redux';
import type { AppDispatch, AppStore, RootState } from './store';
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
export const useAppStore = useStore.withTypes<AppStore>();
These typed hooks should be defined in a separate file such as app/hooks.ts rather than in the store setup file. This prevents circular import dependency issues and makes the hooks easy to import into any component file that needs them.
Benefits of typed hooks:
- useSelector: No need to type
(state: RootState)every time - useDispatch: Correctly typed for thunks and all middleware
- useAppStore: Type-safe access to the store instance
- Prevents circular import issues when defined in a separate file
Using Typed Hooks in Components
With pre-typed hooks, components get full type safety without additional annotations. When using these hooks in your components, you'll notice the improvement immediately. The useAppSelector hook now correctly infers the return type of your selector functions, and useAppDispatch knows that thunks are valid arguments.
import { useAppDispatch, useAppSelector } from '../hooks';
import { increment, decrement, incrementByAmount } from './counterSlice';
function Counter() {
// TypeScript correctly infers the return type
const count = useAppSelector(state => state.counter.value);
const dispatch = useAppDispatch();
return (
<div>
<p>Count: {count}</p>
<button onClick={() => dispatch(increment())}>Increment</button>
<button onClick={() => dispatch(decrement())}>Decrement</button>
<button onClick={() => dispatch(incrementByAmount(5))}>
Add 5
</button>
</div>
);
}
TypeScript will enforce:
- The selector return type matches the expected type
- Dispatched actions have the correct payload types
- Invalid action creators will cause compile errors
This type safety is particularly valuable when building larger React applications with complex state requirements. Our React development services help teams implement these patterns effectively across their applications.
Creating Type-Safe Slices with createSlice
The createSlice function is the heart of Redux Toolkit, combining action creators and reducers into a single definition. When used with TypeScript, createSlice provides excellent type inference for your state and actions, reducing the amount of explicit type annotations you need to write.
Each slice file should define a type for its initial state value so that createSlice can correctly infer the type of state in each case reducer. All generated actions should use the PayloadAction type from Redux Toolkit, which takes the type of the action.payload field as its generic argument.
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import type { RootState } from '../../app/store';
// Define a type for the slice state
interface CounterState {
value: number;
status: 'idle' | 'loading' | 'failed';
}
// Define the initial state using that type
const initialState: CounterState = {
value: 0,
status: 'idle',
};
export const counterSlice = createSlice({
name: 'counter',
// `createSlice` will infer the state type from the `initialState` argument
initialState,
reducers: {
increment: (state) => {
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
// Use the PayloadAction type to declare the contents of `action.payload`
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload;
},
},
});
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
// Other code such as selectors can use the imported `RootState` type
export const selectCount = (state: RootState) => state.counter.value;
export default counterSlice.reducer;
The generated action creators will be correctly typed to accept a payload argument based on the PayloadAction type you provided. For example, incrementByAmount requires a number as its argument, and attempting to pass a string will result in a TypeScript error.
TypeScript automatically handles:
- State type inference from initialState
- Action parameter typing with PayloadAction
- Selector return type based on RootState
- Action creator parameter validation
One important consideration is that TypeScript may unnecessarily tighten the type of the initial state in some cases. If you encounter this, you can work around it by casting the initial state using the as keyword.
Handling Async Operations with createAsyncThunk
Many real-world applications need to handle asynchronous operations like API calls. Redux Toolkit provides createAsyncThunk for this purpose, and it integrates well with TypeScript when used correctly.
When typing createAsyncThunk, you need to specify the type of the argument it accepts, the type of the return value it produces, and any errors it might encounter. The recommended pattern is to use the Pre-Typed createAsyncThunk approach, which embeds the type information directly:
import { createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { fetchUserData, User } from '../api/userAPI';
// Type-safe async thunk
export const fetchUser = createAsyncThunk<
User, // Return type
string, // Argument type (user ID)
{ rejectValue: string } // ThunkAPI config type
>('users/fetchUser', async (userId, thunkApi) => {
try {
const response = await fetchUserData(userId);
return response.data;
} catch (error) {
return thunkApi.rejectWithValue('Failed to fetch user');
}
});
When using extraReducers to handle the pending, fulfilled, and rejected states, TypeScript will correctly infer the action types:
export const userSlice = createSlice({
name: 'user',
initialState: { data: null, loading: false, error: null } as UserState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUser.fulfilled, (state, action: PayloadAction<User>) => {
state.loading = false;
state.data = action.payload;
})
.addCase(fetchUser.rejected, (state, action) => {
state.loading = false;
state.error = action.payload || 'Unknown error';
});
},
});
The builder.addCase method is type-safe, and TypeScript will ensure that you handle all possible action types correctly. If you add a new case reducer for an action that doesn't exist, or if you try to access properties that don't exist on the action payload, TypeScript will flag these issues at compile time.
TypeScript ensures:
- Correct return type for fulfilled actions
- Proper error type for rejected actions
- Payload type validation in case reducers
For robust API integration patterns, consider combining these techniques with proper asynchronous JavaScript patterns in your application architecture.
Writing Type-Safe Selectors
Selectors are functions that extract specific pieces of state from the Redux store. When combined with TypeScript, selectors become type-safe functions that clearly communicate what data they return and what data they need as input.
Basic selectors are straightforward to type. When you define a selector that takes RootState and returns a specific part of the state, TypeScript can infer the return type:
import { RootState } from '../store';
export const selectTodos = (state: RootState) => state.todos.items;
export const selectCompletedTodos = (state: RootState) =>
state.todos.items.filter(todo => todo.completed);
export const selectTodoById = (state: RootState, todoId: string) =>
state.todos.items.find(todo => todo.id === todoId);
When using these selectors with useAppSelector, TypeScript will correctly infer the return type:
const todos = useAppSelector(selectTodos); // TypeScript knows this is Todo[]
const completedTodos = useAppSelector(selectCompletedTodos); // Same type inference
For more complex selector scenarios, you might want to create reusable selector functions that can be composed together. The createSelector function from Redux Toolkit (or Reselect) makes this possible while maintaining type safety:
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from '../store';
export const selectAllItems = (state: RootState) => state.items.all;
export const selectFilter = (state: RootState) => state.items.filter;
export const selectFilteredItems = createSelector(
[selectAllItems, selectFilter],
(items, filter) => {
switch (filter) {
case 'active':
return items.filter(item => !item.completed);
case 'completed':
return items.filter(item => item.completed);
default:
return items;
}
}
);
This memoized selector will only recalculate when either the items or filter changes, improving performance while maintaining type safety throughout the selector chain.
Best practices:
- Export selectors for reuse across components
- Use createSelector for memoization
- Keep selectors pure and testable
- Return narrow types when possible
Memoized selectors help optimize React application performance by preventing unnecessary re-renders and computations.
Slice Types Co-located
Define state interfaces in the same file as your slice
Use PayloadAction
Always type action parameters with PayloadAction<T>
Dedicated Hooks File
Create typed hooks in hooks.ts, not in store files
Type Imports
Use import type for store types to avoid circular deps
Initial State Casting
Use 'as Type' when TypeScript over-tightens inference
Frequently Asked Questions
Do I need to manually type everything in Redux Toolkit?
No. Redux Toolkit is written in TypeScript and provides excellent type inference. You typically only need to specify PayloadAction<T> for action parameters. Most types are inferred automatically from your initial state and reducer configuration.
What's the difference between AppDispatch and regular Dispatch?
AppDispatch is typed with your specific middleware (including thunks), so you can dispatch thunks directly. The generic Dispatch type from Redux core doesn't know about middleware, which would prevent dispatching thunks without additional type casting.
How do I handle circular imports between store and slices?
Use 'import type' syntax when importing types from the store. Type imports are not hoisted and don't cause the same circular dependency issues as regular value imports. This allows safe importing of RootState in slice files.
When should I use createSelector vs regular selectors?
Use createSelector (memoized selectors) when computations depend on derived state or when selectors might be called frequently with the same inputs. Use simple selectors for straightforward state extraction without computation overhead.
How do I type extraReducers for async operations?
TypeScript automatically infers the action types when using builder.addCase with createAsyncThunk. The pending and fulfilled actions use PayloadAction<T> where T is your return type. For rejected actions, use rejectValue in the ThunkAPI config to type the error payload.