Redux Toolkit: The Evolution of State Management in Modern React Applications
by Igor Tosic, Software Engineer
Introduction
Frontend state management has evolved significantly over the years, with Redux being one of the most influential libraries in the React ecosystem. Today, Redux Toolkit (RTK) has emerged as the preferred way to implement Redux, bringing significant improvements to the developer experience. In this article, I'll explore Redux Toolkit's architecture, its integration with NextJS, and practical patterns for building scalable applications.
The Historical Journey of Redux
Redux was born in 2015, created by Dan Abramov and Andrew Clark as a predictable state container for JavaScript applications. It drew inspiration from Facebook's Flux architecture and the Elm programming language's approach to state management. What began as a mere 99 lines of code quickly became the dominant state management solution for React applications. The Redux journey includes several key milestones:
- 2015: Initial release at React Europe conference
- 2019: Redux Toolkit was introduced to address the "boilerplate" complaints
- 2020: Redux Toolkit 1.0 launched with full TypeScript support
- 2021: RTK Query was introduced for data fetching and caching
While Redux provided powerful capabilities like time-travel debugging, it also gained a reputation for verbosity and complexity. Redux Toolkit emerged as a solution to these challenges, streamlining the Redux experience while maintaining its core principles.
Redux Toolkit: Simplifying State Management
Reducers vs ExtraReducers
One of Redux Toolkit's key improvements is how it handles reducers and their interactions with asynchronous operations. Let's explore this using examples from my CRM demo project.
Reducers
In Redux Toolkit, reducers are defined within createSlice
to handle actions that are automatically created for that slice:
// From src/store/clientsSlice.ts
const clientsSlice = createSlice({
name: 'clients',
initialState,
reducers: {
setClients: (state, action: PayloadAction<Client[]>) => {
state.list = action.payload
state.error = null
},
setCurrentClient: (state, action: PayloadAction<Client | null>) => {
state.currentClient = action.payload
state.error = null
},
// More reducers...
},
})
These reducers handle state changes that are "owned" by this slice and are used for direct, synchronous state updates. They work particularly well with Next.js server actions, allowing for clean interaction between server and client state.
ExtraReducers
ExtraReducers, on the other hand, react to actions that are defined elsewhere, typically from async thunks:
// Example of extraReducers in a typical implementation
extraReducers: (builder) => {
builder
.addCase(fetchClients.pending, (state) => {
state.loading.list = true
})
.addCase(fetchClients.fulfilled, (state, action) => {
state.list = action.payload
state.loading.list = false
})
.addCase(fetchClients.rejected, (state, action) => {
state.error = action.error.message || 'Failed to fetch clients'
state.loading.list = false
})
}
ExtraReducers don't generate action creators but instead react to actions from async thunks or other slices. This builder pattern provides better TypeScript support and is ideal for handling asynchronous operations or cross-slice communication.
createSlice vs createAsyncThunk
The relationship between createSlice
and createAsyncThunk
is central to modern Redux applications.
createSlice
createSlice
generates a reducer function, action creators, and action types in one unified API:
// From src/store/taskSlice.ts
const taskSlice = createSlice({
name: "tasks",
initialState,
reducers: {
setTasks: (state, action: PayloadAction<Task[]>) => {
state.list = action.payload;
state.error = null;
},
// More reducers...
}
});
export const { setTasks } = taskSlice.actions;
createAsyncThunk
createAsyncThunk
handles asynchronous operations with standardized lifecycle actions:
// Example async thunk implementation
const fetchTasks = createAsyncThunk('tasks/fetchTasks', async () => {
const response = await fetch('/api/tasks')
return response.json()
})
Each async thunk automatically dispatches:
- A pending action when it starts
- A fulfilled action on success
- A rejected action on error
In my CRM demo project, I've taken a different approach by using server actions for data fetching, which aligns well with Next.js's server components architecture:
// From src/app/actions/tasks.ts
export async function listTasks(params: ListTasksParams = {}): Promise<ListTasksResponse> {
try {
const token = await getAuthToken();
if (!token) {
return {
success: false,
error: "Authentication required",
data: [],
pagination: { /* pagination data */ }
};
}
// API call implementation
// ...
return await response.json();
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Failed to fetch tasks",
data: [],
pagination: { /* pagination data */ }
};
}
}
These server actions are then called from components, and their results are dispatched to the Redux store:
// From src/components/tasks/list/TaskList.tsx
const loadTasks = useCallback(async () => {
try {
dispatch(setLoading({ operation: 'list', isLoading: true }))
dispatch(setError(null))
const result = await listTasks({
status: filters.status,
search: filters.searchTerm,
client_id: filters.clientId,
page: pagination.currentPage,
itemsPerPage: pagination.itemsPerPage,
})
if (result.success && result.data) {
dispatch(setTasks(result.data))
dispatch(setPagination(result.pagination))
} else {
dispatch(setError(result.error || 'Failed to load tasks'))
}
} catch (err) {
dispatch(
setError(
err instanceof Error ? err.message : 'An unexpected error occurred',
),
)
} finally {
dispatch(setLoading({ operation: 'list', isLoading: false }))
setIsInitialMount(false)
}
}, [
dispatch,
filters.status,
filters.searchTerm,
filters.clientId,
isInitialMount,
pagination.currentPage,
pagination.itemsPerPage,
])
This pattern combines Next.js server-side capabilities with client-side state management for a hybrid approach that leverages the strengths of both.
Redux Toolkit vs Context API + useReducer
A common question in the React community is whether to use Redux Toolkit or React's built-in Context API with useReducer. Both have their place, but they serve different needs.
When to Choose Redux Toolkit
Redux Toolkit shines in applications with:
- Complex Global State: When your application has many interconnected state slices
- Team Collaboration: When you need a standardized approach for a team
- Performance Optimization: Redux's selective component updates outperform Context for large state trees
- Developer Tools: Time-travel debugging through Redux DevTools
- Middleware Ecosystem: For logging, persistence, etc.
When to Choose Context + useReducer
Context and useReducer are better suited for:
- Simpler Applications: With limited global state needs
- Component Tree Scoping: When state is only needed in a specific part of your app
- Bundle Size Concerns: No additional dependencies required
- Simpler Setup: Less boilerplate for small features
In CRM demo, Redux Toolkit was the right choice due to the complex state requirements and the need for organized state management across multiple features like clients, tasks, and calendar events.
The Evolution from Switch/Case to Structured Reducers
One of the most significant improvements in Redux Toolkit is how it handles reducers and action types.
Traditional Redux (Pre-RTK)
// Action types as constants
const ADD_TASK = 'tasks/ADD_TASK'
const UPDATE_TASK = 'tasks/UPDATE_TASK'
// Action creators
const addTask = (task) => ({
type: ADD_TASK,
payload: task,
})
// Reducer with switch/case
function tasksReducer(state = [], action) {
switch (action.type) {
case ADD_TASK:
return [...state, action.payload]
case UPDATE_TASK:
return state.map((task) =>
task.id === action.payload.id ? { ...task, ...action.payload } : task,
)
default:
return state
}
}
Redux Toolkit Way
// From src/store/taskSlice.ts
const taskSlice = createSlice({
name: "tasks",
initialState,
reducers: {
addTask: (state, action: PayloadAction<Task>) => {
state.list.push(action.payload);
state.error = null;
},
updateTask: (state, action: PayloadAction<Task>) => {
const index = state.list.findIndex((task) => task.id === action.payload.id);
if (index !== -1) {
state.list[index] = action.payload;
}
if (state.currentTask?.id === action.payload.id) {
state.currentTask = action.payload;
}
state.error = null;
}
}
});
Behind the scenes, Redux Toolkit:
- Generates action types by combining slice name with reducer function name (
tasks/addTask
) - Creates action creators that produce actions with the correct type and payload
- Builds a reducer function with an internal switch/case that maps action types to your reducer functions
- Integrates Immer so you can write "mutating" code that actually produces immutable updates
This approach dramatically reduces boilerplate while maintaining the predictability that made Redux popular.
Typed Custom Hooks for Redux
One improvement that could be made to CRM project is adding typed custom hooks for accessing the Redux store. While the project works without them, adding these hooks would improve type safety and DX:
// Suggested improvement
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { RootState, AppDispatch } from "./store";
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
Using these hooks would provide better TypeScript inference throughout the application:
// Before
const clients = useSelector((state: RootState) => state.clients.list);
const dispatch = useDispatch();
// After (with improved type safety)
const clients = useAppSelector(state => state.clients.list);
const dispatch = useAppDispatch();
Integrating with Next.js: A Modern Approach
Next.js has evolved significantly, especially with the introduction of React Server Components and Server Actions. This has changed how I approach state management in Next.js applications.
In CRM project, I've adopted a hybrid approach:
- Server Actions for data fetching and mutations
- Redux Toolkit for client-side state management
- Component-based patterns for connecting the two
This approach allows us to leverage the strengths of both paradigms:
// From src/app/actions/clients.ts (Server Action)
export async function createClient(clientData: CreateClientData): Promise<ClientResponse> {
try {
const token = await getAuthToken();
if (!token) {
return { success: false, error: "Authentication required" };
}
const response = await fetch(`${API_URL}/clients/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(clientData),
});
if (!response.ok) {
await handleApiError(response);
}
const data = await response.json();
return { success: true, data };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Failed to create client",
};
}
}
// From src/components/clients/form/ClientForm.tsx (Client Component)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
setError("Please fill in all required fields correctly");
return;
}
setLoading(true);
setError(null);
try {
if (mode === "create") {
const result = await createClient(formData);
if (result.success && result.data) {
dispatch(addClient(result.data));
router.push("/clients");
} else {
console.log(result.error);
setError(result.error || "Failed to create client");
}
} else {
// Update client logic...
}
} catch (err) {
setError(err instanceof Error ? err.message : "An unexpected error occurred");
} finally {
setLoading(false);
}
};
The Philosophy of State Management
Beyond the technical details, state management is about how we conceptualize our applications. Redux encourages us to think of state as a "single source of truth" that drives our UI, rather than the other way around.
In CRM application, this philosophy manifests in how we structure our state. For example, our client state not only tracks the list of clients but also loading states, errors, filters, pagination, and more:
// From src/types/index.ts
export interface ClientsState {
list: Client[];
currentClient: Client | null;
loading: LoadingState;
error: string | null;
filters: FilterState;
pagination: PaginationState;
sorting: SortingState<Client>;
}
This comprehensive approach ensures that our UI is always driven by the state, making our application more predictable and easier to debug.
Conclusion
Redux Toolkit represents a significant evolution in how we manage state in React applications. By addressing the pain points of traditional Redux while maintaining its core principles, RTK provides a powerful yet developer-friendly approach to state management.
In this Next.js CRM application, we've seen how RTK integrates with modern Next.js features like Server Actions to create a hybrid architecture that leverages the strengths of both client and server state management. This approach allows us to build complex, interactive applications while maintaining a clear separation of concerns.
As the React ecosystem continues to evolve, the patterns and practices we've explored will remain valuable, even as the specific technologies change. The fundamental principles of predictable state changes, clear data flow, and component-driven architecture will continue to guide us in building robust, maintainable applications.
In the next article in this series, I will explore state management in Svelte and SvelteKit 5, examining how Svelte's unique approach with runes compares to the patterns we've discussed here.