Redux Fundamentals: State Management for JavaScript Apps
Complete guide to Redux — store, actions, reducers, middleware, normalized state, and modern best practices
Introduction
Redux is a predictable state container for JavaScript applications. It helps you manage global state in a single store, making state changes predictable and debuggable. While Redux Toolkit simplifies modern Redux, understanding the fundamentals is essential.
Core Concepts
The Three Principles
- Single source of truth — One store for the entire app
- State is read-only — Only changed by dispatching actions
- Changes are pure functions — Reducers take state + action → new state
The Data Flow
Action → Dispatch → Reducer → Store → UI (re-render)
// 1. Create store
import { createStore } from 'redux';
// 2. Define reducer
function counterReducer(state = { count: 0 }, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
case 'DECREMENT':
return { count: state.count - 1 };
default:
return state;
}
}
// 3. Create store with reducer
const store = createStore(counterReducer);
// 4. Dispatch actions
store.dispatch({ type: 'INCREMENT' });
store.dispatch({ type: 'INCREMENT' });
console.log(store.getState()); // { count: 2 }
// 5. Subscribe to changes
store.subscribe(() => {
console.log('State updated:', store.getState());
});
Actions
Actions are plain objects describing what happened:
// Action with type only
{ type: 'INCREMENT' }
// Action with payload
{ type: 'ADD_TODO', payload: { text: 'Learn Redux' } }
// Action creator functions
function addTodo(text) {
return { type: 'ADD_TODO', payload: { text } };
}
// Async action with Redux Thunk
function fetchUser(id) {
return async (dispatch) => {
dispatch({ type: 'FETCH_USER_REQUEST' });
try {
const user = await api.fetchUser(id);
dispatch({ type: 'FETCH_USER_SUCCESS', payload: user });
} catch (error) {
dispatch({ type: 'FETCH_USER_FAILURE', error: error.message });
}
};
}
Reducers
Reducers specify how state changes in response to actions:
const initialState = {
items: [],
filter: 'all',
loading: false
};
function todosReducer(state = initialState, action) {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
items: [...state.items, { id: Date.now(), text: action.payload, done: false }]
};
case 'TOGGLE_TODO':
return {
...state,
items: state.items.map(item =>
item.id === action.payload ? { ...item, done: !item.done } : item
)
};
case 'REMOVE_TODO':
return {
...state,
items: state.items.filter(item => item.id !== action.payload)
};
case 'SET_FILTER':
return { ...state, filter: action.payload };
default:
return state;
}
}
Reducer rules:
- Never mutate state — always return new objects
- Return the current state for unknown actions
- Keep reducers pure — no side effects
Store
import { createStore, combineReducers, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
// Combine multiple reducers
const rootReducer = combineReducers({
todos: todosReducer,
filter: filterReducer,
user: userReducer
});
// Create store with middleware
const store = createStore(
rootReducer,
applyMiddleware(thunk)
);
// Access state
store.getState();
// Dispatch actions
store.dispatch(addTodo('Learn Redux'));
// Subscribe to changes
const unsubscribe = store.subscribe(() => {
console.log(store.getState());
});
// Unsubscribe when done
unsubscribe();
Middleware
Middleware extends Redux with custom functionality:
// Logger middleware
const logger = (store) => (next) => (action) => {
console.log('Before:', store.getState());
console.log('Action:', action);
const result = next(action);
console.log('After:', store.getState());
return result;
};
// Error handling middleware
const errorHandler = (store) => (next) => (action) => {
try {
return next(action);
} catch (error) {
console.error('Redux error:', error);
store.dispatch({ type: 'ERROR_OCCURRED', error: error.message });
}
};
Normalizing State
Store data in a normalized shape for efficient updates:
// ❌ Nested state — hard to update
const state = {
posts: [
{
id: 1,
title: 'Post 1',
author: { id: 1, name: 'Alice' },
comments: [
{ id: 1, text: 'Great!', author: { id: 2, name: 'Bob' } }
]
}
]
};
// ✅ Normalized state — easy to update
const state = {
posts: {
byId: {
1: { id: 1, title: 'Post 1', authorId: 1, commentIds: [1] }
},
allIds: [1]
},
users: {
byId: {
1: { id: 1, name: 'Alice' },
2: { id: 2, name: 'Bob' }
},
allIds: [1, 2]
},
comments: {
byId: {
1: { id: 1, text: 'Great!', authorId: 2, postId: 1 }
},
allIds: [1]
}
};
// Update a user once — reflected everywhere
function usersReducer(state, action) {
switch (action.type) {
case 'UPDATE_USER':
return {
...state,
byId: {
...state.byId,
[action.payload.id]: { ...state.byId[action.payload.id], ...action.payload }
}
};
}
}
Selectors
Encapsulate state access logic:
// Basic selectors
const getTodos = (state) => state.todos.items;
const getFilter = (state) => state.todos.filter;
// Derived selectors
const getFilteredTodos = (state) => {
const todos = getTodos(state);
const filter = getFilter(state);
switch (filter) {
case 'active': return todos.filter(t => !t.done);
case 'done': return todos.filter(t => t.done);
default: return todos;
}
};
// Memoized selectors with reselect
import { createSelector } from 'reselect';
const getFilteredTodos = createSelector(
[getTodos, getFilter],
(todos, filter) => {
switch (filter) {
case 'active': return todos.filter(t => !t.done);
case 'done': return todos.filter(t => t.done);
default: return todos;
}
}
);
React-Redux Integration
import { Provider, useSelector, useDispatch } from 'react-redux';
// Wrap app with Provider
function App() {
return (
<Provider store={store}>
<TodoList />
</Provider>
);
}
// Use hooks in components
function TodoList() {
const todos = useSelector(state => state.todos.items);
const dispatch = useDispatch();
return (
<ul>
{todos.map(todo => (
<li key={todo.id} onClick={() => dispatch(toggleTodo(todo.id))}>
{todo.text}
</li>
))}
</ul>
);
}
Best Practices
Do's
// ✅ Use Redux Toolkit for new projects
import { configureStore, createSlice } from '@reduxjs/toolkit';
// ✅ Normalize relational data
// ✅ Use selectors for state access
// ✅ Keep reducers pure
// ✅ Use middleware for side effects
// ✅ Dispatch actions, never mutate state directly
Don'ts
// ❌ Don't put everything in Redux
// Local state belongs in components
// ❌ Don't mutate state in reducers
state.items.push(newItem); // Wrong!
// ❌ Don't use Redux for server cache
// Use React Query or RTK Query instead
// ❌ Don't create deep nesting in state
// Normalize instead
// ❌ Don't forget to handle loading/error states
Summary Table
| Concept | Purpose | Example |
|---|---|---|
| Store | Single state container | createStore(reducer) |
| Action | Describe what happened | { type: 'ADD', payload } |
| Reducer | Compute new state | (state, action) => newState |
| Dispatch | Send action to store | store.dispatch(action) |
| Selector | Extract state | state => state.todos |
| Middleware | Extend Redux | Thunks, logging, error handling |
Conclusion
Redux provides predictable state management through:
- Single store — One source of truth
- Actions — Describe state changes
- Reducers — Pure functions computing new state
- Middleware — Handle side effects
For new projects, use Redux Toolkit which simplifies all of the above. For existing Redux codebases, understanding these fundamentals helps you maintain and refactor effectively.
#redux #state-management #javascript #react #web-development