Using TypeScript Redux Toolkit

Build type-safe React applications with Redux Toolkit's powerful state management and TypeScript's compile-time error detection.

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.

Type-Safe State Management

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.

Best Practices for TypeScript Redux Toolkit

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.

Build Type-Safe React Applications

Redux Toolkit combined with TypeScript provides a robust foundation for managing application state with confidence. Start implementing these patterns in your projects today.