Redux Tutorial: State Management for Large React Applications

Redux is a JavaScript library for managing application state in a predictable and scalable way. It provides a centralized store where all application state lives, making it easier to track changes and debug applications.

Redux is particularly useful for complex applications with multiple components that need to share state. It implements the Flux architecture pattern and has become the de-facto standard for state management in React applications.

Redux in One Sentence:
Redux is a predictable state management library that uses actions, reducers, and a store to manage application state in a centralized location accessible from any component.

Why Use Redux?

Problems Redux Solves

  • Prop Drilling: Avoid passing props through multiple component levels
  • State Consistency: Ensure all components access the same state
  • Time-Travel Debugging: Track every state change for debugging
  • Middleware Support: Intercept and modify actions before they reach reducers
  • Predictability: Pure functions ensure consistent state transitions

When to Use Redux

Use Redux When Consider Alternatives
Large, complex applications with shared state Simple apps with minimal state
Multiple components need the same data Only a few components share state
Complex async operations Simple data fetching
Need time-travel debugging Standard debugging is sufficient
✅ Recommendation: Start simple with Context API and useState. Use Redux when your app grows in complexity.

Core Redux Concepts

1. Actions

Actions are plain JavaScript objects that describe what happened. They have a type property and optional payload:

// Action examples
const addTodoAction = {
type: ‘todos/addTodo’,
payload: { id: 1, text: ‘Learn Redux’ }
};const toggleTodoAction = {
type: ‘todos/toggleTodo’,
payload: 1
};

2. Reducers

Reducers are pure functions that take the current state and an action, returning a new state:

function todosReducer(state = [], action) {
switch (action.type) {
case ‘todos/addTodo’:
return […state, action.payload];
case ‘todos/removeTodo’:
return state.filter(todo => todo.id !== action.payload);
default:
return state;
}
}

3. Store

The store holds the entire application state. It dispatches actions and notifies subscribers of state changes:

import { createStore } from ‘redux’;const store = createStore(todosReducer);

// Get current state
console.log(store.getState());

// Dispatch an action
store.dispatch(addTodoAction);

// Subscribe to changes
store.subscribe(() => {
console.log(‘State updated:’, store.getState());
});

Redux Flow

  1. Component dispatches an action
  2. Store receives the action
  3. Reducer processes the action and returns new state
  4. Store updates with new state
  5. Subscribers are notified of the change
  6. Components re-render with new state

Setting Up Redux with Redux Toolkit

Installation

Redux Toolkit is the recommended way to use Redux. It simplifies setup and provides helpful utilities:

npm install @reduxjs/toolkit react-redux

Basic Setup Example

// main.jsx
import { Provider } from ‘react-redux’;
import store from ‘./store’;
import App from ‘./App’;export default function Root() {
return (
<Provider store={store}>
<App />
</Provider>
);
}

Redux Toolkit Benefits:
Redux Toolkit includes: configureStore for setup, createSlice for reducers and actions, and immer for immutable updates.

Creating Slices with createSlice

What is a Slice?

A slice contains a reducer and its associated actions in one place. Redux Toolkit’s createSlice automates action creator generation:

Todo Slice Example

// slices/todoSlice.js
import { createSlice } from ‘@reduxjs/toolkit’;const todoSlice = createSlice({
name: ‘todos’,
initialState: {
items: [],
loading: false,
error: null
},
reducers: {
addTodo: (state, action) => {
state.items.push(action.payload);
},
removeTodo: (state, action) => {
state.items = state.items.filter(
todo => todo.id !== action.payload
);
},
toggleTodo: (state, action) => {
const todo = state.items.find(
t => t.id === action.payload
);
if (todo) {
todo.completed = !todo.completed;
}
}
}
});

export const { addTodo, removeTodo, toggleTodo } = todoSlice.actions;
export default todoSlice.reducer;

Handling Async Operations

import { createAsyncThunk } from ‘@reduxjs/toolkit’;export const fetchTodos = createAsyncThunk(
‘todos/fetchTodos’,
async () => {
const response = await fetch(‘/api/todos’);
return response.json();
}
);

const todoSlice = createSlice({
name: ‘todos’,
initialState: { items: [], loading: false },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => {
state.loading = true;
})
.addCase(fetchTodos.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload;
})
.addCase(fetchTodos.rejected, (state) => {
state.loading = false;
});
}
});

Configuring the Store

Setting Up configureStore

// store.js
import { configureStore } from ‘@reduxjs/toolkit’;
import todoReducer from ‘./slices/todoSlice’;
import userReducer from ‘./slices/userSlice’;export const store = configureStore({
reducer: {
todos: todoReducer,
user: userReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat([
// Add custom middleware here
])
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

Multiple Reducers

Combine multiple slices to create a complex state tree:

const store = configureStore({
reducer: {
todos: todoReducer,
user: userReducer,
auth: authReducer,
ui: uiReducer,
}
});// State structure:
// {
// todos: { items: [], loading: false },
// user: { name: ‘John’, email: ‘john@example.com’ },
// auth: { token: null, isAuthenticated: false },
// ui: { sidebarOpen: true, darkMode: false }
// }

Using Redux Hooks

useSelector Hook

Access state from Redux store in components:

import { useSelector } from ‘react-redux’;export default function TodoList() {
const todos = useSelector((state) => state.todos.items);
const loading = useSelector((state) => state.todos.loading);

if (loading) return <p>Loading…</p>;

return (
<ul>
{todos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
);
}

useDispatch Hook

Dispatch actions from components:

import { useDispatch } from ‘react-redux’;
import { addTodo, removeTodo } from ‘./slices/todoSlice’;export default function AddTodo() {
const dispatch = useDispatch();

const handleAdd = () => {
dispatch(addTodo({
id: Date.now(),
text: ‘New todo’,
completed: false
}));
};

return <button onClick={handleAdd}>Add Todo</button>;
}

Combined Example

import { useSelector, useDispatch } from ‘react-redux’;
import { toggleTodo, removeTodo } from ‘./slices/todoSlice’;export default function TodoItem({ id }) {
const dispatch = useDispatch();
const todo = useSelector(
(state) => state.todos.items.find(t => t.id === id)
);

if (!todo) return null;

return (
<li>
<input
type=”checkbox”
checked={todo.completed}
onChange={() => dispatch(toggleTodo(id))}
/>
<span>{todo.text}</span>
<button onClick={() => dispatch(removeTodo(id))}>
Delete
</button>
</li>
);
}

Async Actions with createAsyncThunk

Handling Loading States

export const fetchUser = createAsyncThunk(
‘user/fetchUser’,
async (userId, { rejectWithValue }) => {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(‘Failed to fetch’);
}
return await response.json();
} catch (error) {
return rejectWithValue(error.message);
}
}
);const userSlice = createSlice({
name: ‘user’,
initialState: {
data: null,
loading: false,
error: null
},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = false;
state.data = action.payload;
})
.addCase(fetchUser.rejected, (state, action) => {
state.loading = false;
state.error = action.payload;
});
}
});

Using Async Actions in Components

import { useEffect } from ‘react’;
import { useDispatch, useSelector } from ‘react-redux’;
import { fetchUser } from ‘./slices/userSlice’;export default function UserProfile({ userId }) {
const dispatch = useDispatch();
const { data: user, loading, error } = useSelector(
(state) => state.user
);

useEffect(() => {
dispatch(fetchUser(userId));
}, [userId, dispatch]);

if (loading) return <p>Loading…</p>;
if (error) return <p>Error: {error}</p>;
if (!user) return null;

return <div><h1>{user.name}</h1></div>;
}

Selectors and Reselect

Creating Selectors

Extract selection logic into reusable functions:

// selectors/todoSelectors.js
export const selectAllTodos = (state) => state.todos.items;export const selectCompletedTodos = (state) =>
state.todos.items.filter(todo => todo.completed);

export const selectActiveTodos = (state) =>
state.todos.items.filter(todo => !todo.completed);

export const selectTodoCount = (state) =>
state.todos.items.length;

export const selectTodoById = (id) => (state) =>
state.todos.items.find(todo => todo.id === id);

Using Selectors in Components

import { useSelector } from ‘react-redux’;
import { selectActiveTodos, selectTodoCount } from ‘./selectors/todoSelectors’;export default function TodoStats() {
const activeTodos = useSelector(selectActiveTodos);
const count = useSelector(selectTodoCount);

return (
<div>
<p>Total: {count}</p>
<p>Active: {activeTodos.length}</p>
</div>
);
}

Memoized Selectors with Reselect

import { createSelector } from ‘@reduxjs/toolkit’;export const selectTodos = (state) => state.todos.items;

// Memoized selector – only recalculates if todos change
export const selectActiveTodos = createSelector(
[selectTodos],
(todos) => todos.filter(todo => !todo.completed)
);

// Selector with parameter
export const selectTodosByStatus = createSelector(
[selectTodos, (_, status) => status],
(todos, status) =>
status === ‘active’
? todos.filter(t => !t.completed)
: todos.filter(t => t.completed)
);

Best Practices

1. Normalize State Shape

Keep state flat and avoid deeply nested data:

// ❌ Don’t: Nested structure
const badState = {
users: [
{
id: 1,
name: ‘John’,
posts: [
{ id: 1, title: ‘Post 1’ }
]
}
]
};// ✅ Do: Normalized structure
const goodState = {
users: {
byId: { 1: { id: 1, name: ‘John’ } },
allIds: [1]
},
posts: {
byId: { 1: { id: 1, title: ‘Post 1’, userId: 1 } },
allIds: [1]
}
};

2. Keep Reducers Pure

// ❌ Don’t: Mutate state
const badReducer = (state, action) => {
state.todos.push(action.payload);
return state;
};// ✅ Do: Return new state
const goodReducer = (state = [], action) => {
switch (action.type) {
case ‘ADD_TODO’:
return […state, action.payload];
default:
return state;
}
};

3. Use Redux DevTools

// Configure store with DevTools (automatically included with configureStore)
import { configureStore } from ‘@reduxjs/toolkit’;const store = configureStore({
reducer: {
todos: todoReducer,
}
// DevTools integration is automatic!
});

// Install Redux DevTools browser extension to debug

4. Handle Side Effects Properly

  • Use createAsyncThunk for API calls
  • Never put side effects in reducers
  • Handle loading and error states explicitly
  • Abort ongoing requests when components unmount

5. Code Splitting with Lazy Reducers

import { injectReducer } from ‘./store’;export default function LazyFeature() {
injectReducer(‘lazyFeature’, lazyReducer);
return <div>…</div>;
}

© 2025 JavaScript UX. Redux State Management Tutorial.

Leave a Reply

Your email address will not be published. Required fields are marked *