TypeScript Redux
Introduction
Redux is a predictable state container for JavaScript applications that helps you manage application state in a consistent way. When combined with TypeScript, Redux becomes even more powerful, providing type safety and improved developer experience. This guide will walk you through how to use Redux with TypeScript, ensuring your state management is both robust and type-safe.
What is Redux?
Redux is based on three fundamental principles:
- Single source of truth: The entire application state is stored in a single store.
- State is read-only: The only way to change state is to emit an action.
- Changes are made with pure functions: Reducers are pure functions that take the previous state and an action to return the new state.
Adding TypeScript to Redux gives you:
- Type safety for actions, reducers, and state
- Better autocompletion in your IDE
- Safer refactoring
- Clearer intent through type definitions
Setting Up TypeScript Redux
Installation
First, let's install Redux along with TypeScript typings:
npm install redux react-redux @reduxjs/toolkit
npm install --save-dev @types/react-redux
Basic Setup with TypeScript
Step 1: Define Your State Types
Start by defining the shape of your application state:
// src/types/store.ts
export interface TodoItem {
id: number;
text: string;
completed: boolean;
}
export interface AppState {
todos: TodoItem[];
loading: boolean;
error: string | null;
}
Step 2: Define Action Types
Define your action types using TypeScript:
// src/types/actions.ts
export const ADD_TODO = 'ADD_TODO';
export const TOGGLE_TODO = 'TOGGLE_TODO';
export const FETCH_TODOS = 'FETCH_TODOS';
export const FETCH_TODOS_SUCCESS = 'FETCH_TODOS_SUCCESS';
export const FETCH_TODOS_FAILURE = 'FETCH_TODOS_FAILURE';
interface AddTodoAction {
type: typeof ADD_TODO;
payload: {
text: string;
};
}
interface ToggleTodoAction {
type: typeof TOGGLE_TODO;
payload: {
id: number;
};
}
interface FetchTodosAction {
type: typeof FETCH_TODOS;
}
interface FetchTodosSuccessAction {
type: typeof FETCH_TODOS_SUCCESS;
payload: {
todos: TodoItem[];
};
}
interface FetchTodosFailureAction {
type: typeof FETCH_TODOS_FAILURE;
payload: {
error: string;
};
}
export type TodoActionTypes =
| AddTodoAction
| ToggleTodoAction
| FetchTodosAction
| FetchTodosSuccessAction
| FetchTodosFailureAction;
Step 3: Create Action Creators
Now, create type-safe action creators:
// src/actions/todoActions.ts
import { TodoItem, TodoActionTypes, ADD_TODO, TOGGLE_TODO, FETCH_TODOS, FETCH_TODOS_SUCCESS, FETCH_TODOS_FAILURE } from '../types';
export const addTodo = (text: string): TodoActionTypes => {
return {
type: ADD_TODO,
payload: {
text
}
};
};
export const toggleTodo = (id: number): TodoActionTypes => {
return {
type: TOGGLE_TODO,
payload: {
id
}
};
};
export const fetchTodos = (): TodoActionTypes => {
return {
type: FETCH_TODOS
};
};
export const fetchTodosSuccess = (todos: TodoItem[]): TodoActionTypes => {
return {
type: FETCH_TODOS_SUCCESS,
payload: {
todos
}
};
};
export const fetchTodosFailure = (error: string): TodoActionTypes => {
return {
type: FETCH_TODOS_FAILURE,
payload: {
error
}
};
};
Step 4: Create Reducers
Create type-safe reducers that use the types we've defined:
// src/reducers/todoReducer.ts
import {
AppState,
TodoActionTypes,
ADD_TODO,
TOGGLE_TODO,
FETCH_TODOS,
FETCH_TODOS_SUCCESS,
FETCH_TODOS_FAILURE
} from '../types';
const initialState: AppState = {
todos: [],
loading: false,
error: null
};
export const todoReducer = (
state = initialState,
action: TodoActionTypes
): AppState => {
switch (action.type) {
case ADD_TODO:
return {
...state,
todos: [
...state.todos,
{
id: state.todos.length + 1,
text: action.payload.text,
completed: false
}
]
};
case TOGGLE_TODO:
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload.id
? { ...todo, completed: !todo.completed }
: todo
)
};
case FETCH_TODOS:
return {
...state,
loading: true,
error: null
};
case FETCH_TODOS_SUCCESS:
return {
...state,
loading: false,
todos: action.payload.todos
};
case FETCH_TODOS_FAILURE:
return {
...state,
loading: false,
error: action.payload.error
};
default:
return state;
}
};
Step 5: Create and Configure Store
Configure your Redux store:
// src/store/index.ts
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import { todoReducer } from '../reducers/todoReducer';
import { AppState } from '../types';
export const store = createStore(
todoReducer,
applyMiddleware(thunk)
);
export type AppDispatch = typeof store.dispatch;
Using Redux with React and TypeScript
Let's see how to use our typed Redux store in a React application:
Setup Provider
// src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { store } from './store';
import App from './App';
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
Create Typed Hooks
For better TypeScript integration, create typed versions of useDispatch
and useSelector
:
// src/hooks/reduxHooks.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { AppState } from '../types/store';
import type { AppDispatch } from '../store';
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<AppState> = useSelector;
Example Component
Here's how to use TypeScript with Redux in a React component:
// src/components/TodoList.tsx
import React, { useState, useEffect } from 'react';
import { useAppSelector, useAppDispatch } from '../hooks/reduxHooks';
import { addTodo, toggleTodo, fetchTodos } from '../actions/todoActions';
const TodoList: React.FC = () => {
const [newTodo, setNewTodo] = useState('');
const dispatch = useAppDispatch();
const { todos, loading, error } = useAppSelector(state => state);
useEffect(() => {
dispatch(fetchTodos());
}, [dispatch]);
const handleAddTodo = (e: React.FormEvent) => {
e.preventDefault();
if (newTodo.trim()) {
dispatch(addTodo(newTodo.trim()));
setNewTodo('');
}
};
const handleToggle = (id: number) => {
dispatch(toggleTodo(id));
};
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<div>
<h1>Todo List</h1>
<form onSubmit={handleAddTodo}>
<input
type="text"
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
placeholder="Add a new todo"
/>
<button type="submit">Add</button>
</form>
<ul>
{todos.map(todo => (
<li
key={todo.id}
onClick={() => handleToggle(todo.id)}
style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
>
{todo.text}
</li>
))}
</ul>
</div>
);
};
export default TodoList;
Using Redux Toolkit with TypeScript
Redux Toolkit simplifies Redux configuration and is the recommended approach for new Redux projects.
Setup with Redux Toolkit
npm install @reduxjs/toolkit react-redux
Creating a Slice
// src/features/todos/todosSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { TodoItem } from '../../types/store';
interface TodosState {
todos: TodoItem[];
loading: boolean;
error: string | null;
}
const initialState: TodosState = {
todos: [],
loading: false,
error: null
};
// Async thunk for fetching todos
export const fetchTodos = createAsyncThunk(
'todos/fetchTodos',
async (_, { rejectWithValue }) => {
try {
const response = await fetch('https://api.example.com/todos');
if (!response.ok) throw new Error('Server Error');
const data: TodoItem[] = await response.json();
return data;
} catch (error) {
return rejectWithValue('Failed to fetch todos');
}
}
);
const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
addTodo: (state, action: PayloadAction<string>) => {
state.todos.push({
id: state.todos.length + 1,
text: action.payload,
completed: false
});
},
toggleTodo: (state, action: PayloadAction<number>) => {
const todo = state.todos.find(todo => todo.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
}
},
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchTodos.fulfilled, (state, action: PayloadAction<TodoItem[]>) => {
state.loading = false;
state.todos = action.payload;
})
.addCase(fetchTodos.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string;
});
}
});
export const { addTodo, toggleTodo } = todosSlice.actions;
export default todosSlice.reducer;
Configure Store
// src/store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from '../features/todos/todosSlice';
export const store = configureStore({
reducer: {
todos: todosReducer
}
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Update Hooks
// src/hooks/reduxHooks.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from '../store';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
Using the Redux Toolkit Slice in a Component
// src/components/TodoListToolkit.tsx
import React, { useState, useEffect } from 'react';
import { useAppSelector, useAppDispatch } from '../hooks/reduxHooks';
import { addTodo, toggleTodo, fetchTodos } from '../features/todos/todosSlice';
const TodoListToolkit: React.FC = () => {
const [newTodo, setNewTodo] = useState('');
const dispatch = useAppDispatch();
const { todos, loading, error } = useAppSelector(state => state.todos);
useEffect(() => {
dispatch(fetchTodos());
}, [dispatch]);
const handleAddTodo = (e: React.FormEvent) => {
e.preventDefault();
if (newTodo.trim()) {
dispatch(addTodo(newTodo.trim()));
setNewTodo('');
}
};
const handleToggle = (id: number) => {
dispatch(toggleTodo(id));
};
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<div>
<h1>Todo List with Redux Toolkit</h1>
<form onSubmit={handleAddTodo}>
<input
type="text"
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
placeholder="Add a new todo"
/>
<button type="submit">Add</button>
</form>
<ul>
{todos.map(todo => (
<li
key={todo.id}
onClick={() => handleToggle(todo.id)}
style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
>
{todo.text}
</li>
))}
</ul>
</div>
);
};
export default TodoListToolkit;
Redux Flow with TypeScript
The Redux flow with TypeScript can be visualized as follows:
Best Practices for TypeScript Redux
- Use TypeScript interfaces for state: Define the shape of your state with TypeScript interfaces.
- Type your actions: Create union types for actions to ensure type safety.
- Use typed selectors: Ensure your selectors know the type of state they're working with.
- Leverage Redux Toolkit: It significantly reduces boilerplate code and provides better TypeScript integration.
- Create typed hooks: Create custom hooks with proper typing for
useDispatch
anduseSelector
. - Use action creators: Type-safe action creators make it easier to dispatch actions correctly.
- Use constants for action types: This prevents typos and makes refactoring easier.
Common Patterns
Normalized State Shape
For complex applications, normalize your state:
interface NormalizedState<T> {
byId: Record<string, T>;
allIds: string[];
}
interface TodosState {
items: NormalizedState<TodoItem>;
loading: boolean;
error: string | null;
}
const initialState: TodosState = {
items: {
byId: {},
allIds: []
},
loading: false,
error: null
};
Module Augmentation for Thunk Types
// src/types/redux-thunk.d.ts
import { ThunkAction } from 'redux-thunk';
import { Action } from 'redux';
import { RootState } from '../store';
declare module 'redux' {
export interface Dispatch<A extends Action = AnyAction> {
<R>(thunk: ThunkAction<R, RootState, unknown, A>): R;
}
}
Summary
TypeScript enhances Redux by providing type safety and improving the developer experience. By clearly defining your state shape, action types, and ensuring type safety throughout your application, you can catch errors at compile time rather than runtime.
Redux Toolkit makes working with TypeScript even easier by reducing boilerplate and providing built-in support for TypeScript. It's the recommended approach for new Redux projects, especially those using TypeScript.
Additional Resources
Exercises
- Create a simple counter application using TypeScript and Redux.
- Extend the todo application to include categories and filters.
- Implement user authentication with TypeScript and Redux.
- Create a shopping cart with Redux Toolkit and TypeScript.
- Implement error handling middleware for your Redux application.
By completing these exercises, you'll gain practical experience with TypeScript and Redux, and develop a deeper understanding of how they work together to create robust applications.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)