Introduction to Redux State Management
Redux is a predictable state container for JavaScript applications, originally designed for React but now used across many frameworks. At its core, Redux enforces a unidirectional data flow and a single source of truth, making application state easier to reason about, debug, and test. While the base library is intentionally minimal, the ecosystem around it has grown into a rich collection of patterns and libraries that solve common pain points such as boilerplate, async logic, and normalized data.
This tutorial walks through the fundamentals of Redux, explores established patterns for structuring state and logic, and introduces the most widely used libraries that complement Redux in modern applications.
Why Redux Matters
As applications grow, managing shared state through component props or context alone becomes brittle. Redux addresses this by centralizing state in a single store and requiring all changes to flow through dispatched actions and pure reducer functions. This gives you:
- Predictability: Reducers are pure functions, so the same state and action always produce the same next state.
- Debuggability: Tools like Redux DevTools enable time-travel debugging and action replay.
- Testability: Pure reducers and action creators are trivial to unit test.
- Scalability: A clear separation between state shape, update logic, and UI makes large codebases maintainable.
Core Concepts
Redux revolves around three principles: a single source of truth, read-only state, and changes made by pure functions. The data flow is: UI dispatches an action, the store passes the action and current state to the root reducer, the reducer returns new state, and the UI re-renders.
The Store, Actions, and Reducers
A minimal Redux setup looks like this:
import { createStore } from 'redux';
// Action type
const INCREMENT = 'counter/increment';
// Action creator
const increment = (payload = 1) => ({
type: INCREMENT,
payload,
});
// Reducer
const initialState = { count: 0 };
function counterReducer(state = initialState, action) {
switch (action.type) {
case INCREMENT:
return { ...state, count: state.count + action.payload };
default:
return state;
}
}
// Store
const store = createStore(counterReducer);
// Usage
store.dispatch(increment(5));
console.log(store.getState()); // { count: 5 }
While this classic pattern is instructive, modern Redux development rarely writes code this verbose. The community has converged on patterns and libraries that reduce boilerplate while preserving the underlying model.
Modern Redux with Redux Toolkit
Redux Toolkit (RTK) is the official, recommended way to write Redux logic. It bundles utilities like configureStore, createSlice, and createAsyncThunk to eliminate boilerplate and enforce best practices such as immutable updates via Immer.
Creating a Slice
A slice combines the reducer logic and action creators for a single feature:
import { createSlice } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: { count: 0, status: 'idle' },
reducers: {
increment(state, action) {
// Immer allows "mutating" syntax for immutable updates
state.count += action.payload;
},
decrement(state) {
state.count -= 1;
},
reset(state) {
state.count = 0;
},
},
});
export const { increment, decrement, reset } = counterSlice.actions;
export default counterSlice.reducer;
Configuring the Store
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './features/counter/counterSlice';
export const store = configureStore({
reducer: {
counter: counterReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: true,
immutableCheck: true,
}),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Using Redux in React
The react-redux library provides hooks to connect components to the store:
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement } from './features/counter/counterSlice';
import type { RootState } from './store';
export function Counter() {
const count = useSelector((state: RootState) => state.counter.count);
const dispatch = useDispatch();
return (
<div>
<button onClick={() => dispatch(decrement())}>-</button>
<span>{count}</span>
<button onClick={() => dispatch(increment(2))}>+</button>
</div>
);
}
For type-safe dispatch and selector hooks, create pre-typed versions once and reuse them across the app:
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux';
import type { RootState, AppDispatch } from './store';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
Handling Async Logic
Redux reducers must be pure, so async work belongs in middleware. The two dominant approaches are createAsyncThunk for promise-based flows and RTK Query for data fetching.
createAsyncThunk
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
interface User {
id: number;
name: string;
}
export const fetchUser = createAsyncThunk<User, number>(
'user/fetchUser',
async (userId, { rejectWithValue }) => {
try {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error('Failed to fetch user');
return (await res.json()) as User;
} catch (err) {
return rejectWithValue((err as Error).message);
}
}
);
const userSlice = createSlice({
name: 'user',
initialState: { data: null as User | null, status: 'idle', error: null as string | null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.status = 'succeeded';
state.data = action.payload;
})
.addCase(fetchUser.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload as string;
});
},
});
export default userSlice.reducer;
RTK Query
RTK Query is a powerful data-fetching and caching layer built into Redux Toolkit. It eliminates the need to manually write thunks and cache logic for API calls:
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['User'],
endpoints: (builder) => ({
getUser: builder.query<User, number>({
query: (id) => `/users/${id}`,
providesTags: (result, error, id) => [{ type: 'User', id }],
}),
updateUser: builder.mutation<User, { id: number; patch: Partial<User> }>({
query: ({ id, patch }) => ({
url: `/users/${id}`,
method: 'PATCH',
body: patch,
}),
invalidatesTags: (result, error, { id }) => [{ type: 'User', id }],
}),
}),
});
export const { useGetUserQuery, useUpdateUserMutation } = api;
Components consume these hooks directly, gaining automatic caching, refetching, and loading state:
function UserProfile({ userId }: { userId: number }) {
const { data: user, isLoading } = useGetUserQuery(userId);
const [updateUser] = useUpdateUserMutation();
if (isLoading) return <p>Loading...</p>;
if (!user) return <p>No user</p>;
return (
<div>
<h3>{user.name}</h3>
<button onClick={() => updateUser({ id: userId, patch: { name: 'Updated' } })}>
Rename
</button>
</div>
);
}
State Normalization
Storing lists of entities as arrays leads to inefficient lookups and duplicate data. Normalization stores entities in a dictionary keyed by ID, alongside an array of IDs that preserves ordering. Redux Toolkit provides createEntityAdapter for this:
import { createSlice, createEntityAdapter, PayloadAction } from '@reduxjs/toolkit';
interface Post {
id: string;
title: string;
body: string;
}
const postsAdapter = createEntityAdapter<Post>({
sortComparer: (a, b) => a.title.localeCompare(b.title),
});
const postsSlice = createSlice({
name: 'posts',
initialState: postsAdapter.getInitialState(),
reducers: {
postAdded: postsAdapter.addOne,
postUpdated: postsAdapter.updateOne,
postRemoved: postsAdapter.removeOne,
postsReceived(state, action: PayloadAction<Post[]>) {
postsAdapter.setAll(state, action.payload);
},
},
});
export const { postAdded, postUpdated, postRemoved, postsReceived } = postsSlice.actions;
// Pre-built selectors
export const {
selectAll: selectAllPosts,
selectById: selectPostById,
selectIds: selectPostIds,
} = postsAdapter.getSelectors((state: RootState) => state.posts);
export default postsSlice.reducer;
Selector Patterns
Selectors encapsulate the shape of state, keeping components decoupled from the store's internal structure. For derived state that involves filtering or mapping, use reselect (bundled with RTK) to memoize:
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from './store';
const selectPosts = (state: RootState) => state.posts.entities;
const selectFilter = (state: RootState) => state.filters.title;
export const selectFilteredPosts = createSelector(
[selectPosts, selectFilter],
(posts, filter) => {
const all = Object.values(posts);
return filter
? all.filter((p) => p.title.toLowerCase().includes(filter.toLowerCase()))
: all;
}
);
Memoized selectors only recompute when their inputs change, preventing unnecessary renders when unrelated state updates occur.
Middleware Patterns
Middleware sits between dispatching an action and the reducer reaching it, making it ideal for logging, analytics, crash reporting, and side effects. RTK's getDefaultMiddleware already includes thunk, immutability, and serializability checks. You can add custom middleware:
import { configureStore, Middleware } from '@reduxjs/toolkit';
const loggerMiddleware: Middleware = (storeApi) => (next) => (action) => {
console.log('Dispatching:', action);
const result = next(action);
console.log('Next state:', storeApi.getState());
return result;
};
export const store = configureStore({
reducer: rootReducer,
middleware: (getDefault) => getDefault().concat(loggerMiddleware),
});
Best Practices
- Use Redux Toolkit. It is the official recommendation and removes nearly all boilerplate. Avoid hand-writing action types and switch statements unless you have a specific reason.
- Keep state normalized. Store entities in maps keyed by ID. This prevents duplication and makes updates O(1).
- Treat state as immutable. Even though Immer lets you write mutating syntax, never mutate state outside reducers.
- Use selectors. Components should never reach deeply into the store shape. Selectors create a stable interface and enable memoization.
- Separate UI state from domain data. Transient UI state like form inputs or modal visibility often belongs in local component state or a UI slice, not in the same entities as server data.
- Prefer RTK Query for server state. If your state is mostly fetched from an API, RTK Query handles caching, invalidation, and loading flags better than hand-rolled thunks.
- Colocate by feature. Organize folders by feature (e.g.,
features/posts) rather than by file type (e.g.,reducers/,actions/). - Name actions as events, not commands. Use past-tense or descriptive event names like
postAddedrather thanaddPostto decouple intent from implementation. - Avoid putting non-serializable values in state. Functions, Promises, and class instances break time-travel debugging and serialization.
- Type your store. Define
RootStateandAppDispatchand create typed hooks to catch errors at compile time.
Complementary Libraries
- redux-persist: Persists and rehydrates store state to localStorage, AsyncStorage, or other storage engines.
- redux-saga: Uses generator functions for complex async flows and long-running processes. Useful when you need fine-grained control over side effects, though thunks and RTK Query cover most cases today.
- redux-observable: Models side effects as RxJS observables, ideal for debouncing, cancellation, and streaming data.
- reselect: Memoized selector library, included with RTK.
- redux-logger: Console logging middleware for development debugging.
- RTK Query: Built-in data fetching and caching, often replacing the need for external data libraries.
Conclusion
Redux remains a robust choice for managing complex application state, and Redux Toolkit has transformed the developer experience by removing boilerplate and baking in best practices. By combining slices for local feature state, normalized entity adapters for collections, memoized selectors for derived data, and RTK Query for server state, you can build scalable applications with predictable, debuggable state transitions. The key is to reach for Redux when you genuinely need centralized, shared state, and to lean on the ecosystem's patterns and libraries rather than reinventing them. With these tools and conventions in place, Redux scales gracefully from small dashboards to large enterprise applications.