You’ve explored the power of Redux for state management and the efficiency of Redux Toolkit for simplifying boilerplate. Now, let’s bring in TypeScript to truly elevate your Redux development. Integrating TypeScript with Redux provides unparalleled type safety, catches bugs at compile-time, and significantly improves developer experience through better autocompletion and code clarity. It ensures that your state, actions, and data flow are consistent and predictable, especially in large and complex applications.
This article will guide you through the essential steps of applying TypeScript to your Redux setup, covering how to type the store itself, your reducers and actions, and how to effectively use `useSelector`, `useDispatch`, `createSlice`, and `createAsyncThunk` with full type safety.
Typing the Redux Store (`RootState` and `AppDispatch`)
The foundation of type-safe Redux begins with correctly typing your store. When using Redux Toolkit’s `configureStore`, TypeScript can infer the shape of your entire state and the type of your `dispatch` function. It’s best practice to export these inferred types from your store configuration file to be reused throughout your application.
<!-- src/app/store.ts --> import { configureStore } from '@reduxjs/toolkit'; import counterReducer from '../features/counter/counterSlice'; // Assume this exists import postsReducer from '../features/posts/postsSlice'; // Assume this exists export const store = configureStore({ reducer: { counter: counterReducer, posts: postsReducer, }, // devTools: process.env.NODE_ENV !== 'production', // configureStore enables this by default in dev }); // Infer the `RootState` and `AppDispatch` types from the store itself // `RootState` is the inferred type of your entire store's state export type RootState = ReturnType<typeof store.getState>; // `AppDispatch` is the inferred type of the `store.dispatch` function // It includes middleware (like thunks) in its type signature export type AppDispatch = typeof store.dispatch; // You would use these types in your React components when using useSelector and useDispatch
By exporting `RootState` and `AppDispatch`, you create a central place for your core Redux types, allowing TypeScript to provide accurate type checking across your entire application when interacting with the store.
Typing Reducers and Actions
While `createSlice` handles much of this automatically (as we’ll see), understanding how to manually type reducers and actions is foundational.
Typing Reducers (Manual Approach)
A reducer function typically takes the current `state` and an `action` as arguments and returns a new `state`. You need to define an interface or type for your state slice and for the actions it handles.
<!-- Manual Reducer Typing Example --> // 1. Define the State Shape interface CounterState { value: number; } const initialCounterState: CounterState = { value: 0 }; // 2. Define Action Shapes interface IncrementAction { type: 'counter/increment'; } interface DecrementAction { type: 'counter/decrement'; } interface IncrementByAmountAction { type: 'counter/incrementByAmount'; payload: number; } // Union type for all actions the reducer can handle type CounterAction = IncrementAction | DecrementAction | IncrementByAmountAction; // 3. Type the Reducer Function function counterReducer(state: CounterState = initialCounterState, action: CounterAction): CounterState { switch (action.type) { case 'counter/increment': return { ...state, value: state.value + 1 }; case 'counter/decrement': return { ...state, value: state.value - 1 }; case 'counter/incrementByAmount': // TypeScript knows action.payload is a number here return { ...state, value: state.value + action.payload }; default: return state; } }
This manual typing ensures that your reducer always receives and returns the correct state shape and that `action.payload` is correctly typed within each `case` statement. However, `createSlice` simplifies this significantly.
Typing `useSelector` and `useDispatch`
To ensure full type safety when reading from and writing to your Redux store in React components, you should use your `RootState` and `AppDispatch` types with `useSelector` and `useDispatch`.
Typing `useSelector`
When using `useSelector`, you typically pass your `RootState` type to the selector function’s `state` argument. This ensures that TypeScript knows the full shape of your application’s state and can provide autocompletion and type checking for the data you select.
<!-- src/features/counter/CounterDisplay.tsx --> import React from 'react'; import { useSelector } from 'react-redux'; import { RootState } from '../../app/store'; // Import your RootState type const CounterDisplay: React.FC = () => { // The 'state' argument is typed as RootState const count = useSelector((state: RootState) => state.counter.value); const postsStatus = useSelector((state: RootState) => state.posts.status); return ( <div> <h3>Current Count: {count}</h3> <p>Posts Status: {postsStatus}</p> </div> ); }; export default CounterDisplay;
Typing `useDispatch`
For `useDispatch`, it’s best practice to create a custom typed `useDispatch` hook using `AppDispatch`. This ensures that TypeScript knows about all possible actions your application can dispatch, including asynchronous thunks, and provides type checking for your `dispatch` calls.
<!-- src/app/hooks.ts (Recommended approach) --> import { useDispatch, useSelector, type TypedUseSelectorHook } from 'react-redux'; import type { RootState, AppDispatch } from './store'; // Your store types // Use throughout your app instead of plain `useDispatch` and `useSelector` export const useAppDispatch: () => AppDispatch = useDispatch; export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector; <!-- src/features/counter/CounterControls.tsx (Using the typed hooks) --> import React from 'react'; import { useAppDispatch, useAppSelector } from '../../app/hooks'; // Your custom typed hooks import { increment, decrement, incrementByAmount } from './counterSlice'; const CounterControls: React.FC = () => { // Now dispatch is fully typed! const dispatch = useAppDispatch(); // You can still select state using the typed selector const count = useAppSelector((state) => state.counter.value); // state is implicitly RootState const handleIncrement = () => { dispatch(increment()); // Type-safe dispatch }; const handleIncrementByFive = () => { dispatch(incrementByAmount(5)); // '5' must be a number, enforced by types }; return ( <div> <p>Value from useSelector: {count}</p> <button onClick={handleIncrement}>Increment</button> <button onClick={handleIncrementByFive}>Increment by 5</button> </div> ); }; export default CounterControls;
Creating these custom typed hooks (`useAppDispatch`, `useAppSelector`) is the standard and most robust way to interact with your Redux store in TypeScript React applications, as recommended by the Redux Toolkit documentation.
Type-Safe `createSlice` and `createAsyncThunk`
Redux Toolkit is designed with TypeScript in mind, making it incredibly easy to create type-safe slices and async thunks.
Type-Safe `createSlice`
`createSlice` automatically infers many types based on your `initialState` and `reducers` definitions. You primarily need to:
- Define an interface for your slice’s state.
- Use `PayloadAction
` for actions that carry a payload.
<!-- src/features/counter/counterSlice.ts --> import { createSlice, PayloadAction } from '@reduxjs/toolkit'; // 1. Define your slice's state interface interface CounterState { value: number; status: 'idle' | 'loading' | 'failed'; } // Define the initial state (must conform to CounterState) const initialState: CounterState = { value: 0, status: 'idle', }; const counterSlice = createSlice({ name: 'counter', initialState, // TypeScript checks if initialState matches CounterState reducers: { increment: (state) => { // Immer allows direct mutation, types are inferred state.value += 1; }, decrement: (state) => { state.value -= 1; }, // 2. Use PayloadAction<T> for actions with payloads // TypeScript infers `action.payload` will be a number incrementByAmount: (state, action: PayloadAction<number>) => { state.value += action.payload; }, // Example with complex payload // setStatus: (state, action: PayloadAction<'idle' | 'loading' | 'failed'>) => { // state.status = action.payload; // }, }, }); export const { increment, decrement, incrementByAmount } = counterSlice.actions; export default counterSlice.reducer; // The action creators (increment, decrement, incrementByAmount) // and their types are automatically inferred and type-safe! // E.g., incrementByAmount(5) is valid, but incrementByAmount("hello") would be a type error.
Type-Safe `createAsyncThunk`
`createAsyncThunk` requires a bit more explicit typing, especially for its generic arguments, to ensure proper type inference for pending, fulfilled, and rejected states.
<!-- src/features/posts/postsSlice.ts --> import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; import axios from 'axios'; interface Post { userId: number; id: number; title: string; body: string; } interface PostsState { posts: Post[]; status: 'idle' | 'loading' | 'succeeded' | 'failed'; error: string | null; } const initialState: PostsState = { posts: [], status: 'idle', error: null, }; // Type createAsyncThunk: // 1. ReturnType (what the thunk's payload creator returns on success) // 2. ArgType (the type of the argument passed to the thunk, if any) // 3. ThunkApiConfig (for dispatch/getState/extra types if needed) export const fetchPosts = createAsyncThunk<Post[], void, { state: RootState }>( 'posts/fetchPosts', async (_, { rejectWithValue }) => { // '_' indicates no argument needed here (void) try { const response = await axios.get<Post[]>('https://jsonplaceholder.typicode.com/posts'); return response.data; // This becomes the fulfilled payload (Post[]) } catch (error: any) { // Use rejectWithValue for typed error payloads return rejectWithValue(error.response?.data || error.message); } } ); const postsSlice = createSlice({ name: 'posts', initialState, reducers: {}, extraReducers: (builder) => { builder .addCase(fetchPosts.pending, (state) => { state.status = 'loading'; }) // action.payload is now correctly typed as Post[] .addCase(fetchPosts.fulfilled, (state, action: PayloadAction<Post[]>) => { state.status = 'succeeded'; state.posts = action.payload; }) // action.payload is now correctly typed as string (or whatever type you rejected with) .addCase(fetchPosts.rejected, (state, action) => { state.status = 'failed'; // TypeScript correctly infers action.payload if rejectWithValue is used state.error = action.payload as string || action.error.message || 'Unknown error'; }); }, }); export default postsSlice.reducer;
By explicitly defining the generic types for `createAsyncThunk` (especially the return type and argument type), you gain full type safety throughout the async lifecycle, from dispatching the thunk to handling its `pending`, `fulfilled`, and `rejected` actions in your `extraReducers`.
Combining Redux Toolkit’s powerful utilities with TypeScript’s robust type system creates an incredibly efficient, maintainable, and bug-resistant environment for state management in your React applications.
[…] TypeScript with Redux (and Redux Toolkit) […]