React applications tend to get complicated fast. Props start flowing in every direction, components that need the same data live far apart in the tree, and debugging state changes becomes a guessing game. Redux solves this by putting all application state in one place and enforcing a strict, predictable update model.
What is Redux?
Redux is a predictable state container for JavaScript applications. It centralizes your app's state in a single store, so state changes are traceable and easy to reproduce. Redux works with React via the react-redux bindings, but it's not React-specific.
Core Concepts of Redux
Store
The store is the single source of truth. It holds the entire state tree for your app. One store means one place to look when something goes wrong.
Actions
Actions are plain JavaScript objects that describe an intention to change state. Every action must have a type property. Anything else you attach is up to you.
const addAction = {
type: 'ADD_TODO',
payload: {
text: 'Learn Redux',
},
};
Reducers
Reducers are pure functions. They take the previous state and an action, and return a new state. They never mutate the existing state.
function todoReducer(state = [], action) {
switch (action.type) {
case 'ADD_TODO':
return [...state, action.payload];
default:
return state;
}
}
State Tree
The state tree is a plain JavaScript object representing everything your application knows. Because reducers always return new objects instead of modifying existing ones, state changes are predictable and diff-able.
Dispatch
Dispatch is the method on the store that sends an action. The store's reducer processes it and produces the next state.
store.dispatch(addAction);
Setting Up Redux in a React Application
Installing Dependencies
Install Redux and React Redux:
npm install redux react-redux
For TypeScript users, also install the types:
npm install --save-dev @types/redux @types/react-redux
Creating the Store
Create a store.js file and configure the store:
import { createStore } from 'redux';
import rootReducer from './reducers';
const store = createStore(rootReducer);
export default store;
Providing the Store to the App
Wrap your root component with Provider from react-redux. Every component below it can now access the store.
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')
);
Working with Actions and Action Creators
Define action types:
export const ADD_TODO = 'ADD_TODO';
export const REMOVE_TODO = 'REMOVE_TODO';
Create action creators:
export const addTodo = (text) => ({
type: ADD_TODO,
payload: { text },
});
export const removeTodo = (id) => ({
type: REMOVE_TODO,
payload: { id },
});
Understanding Reducers
Create reducers to handle state changes:
import { ADD_TODO, REMOVE_TODO } from '../actions';
const initialState = {
todos: [],
};
function todoReducer(state = initialState, action) {
switch (action.type) {
case ADD_TODO:
const newTodo = {
id: Date.now(),
text: action.payload.text,
};
return { ...state, todos: [...state.todos, newTodo] };
case REMOVE_TODO:
return {
...state,
todos: state.todos.filter((todo) => todo.id !== action.payload.id),
};
default:
return state;
}
}
export default todoReducer;
Connecting React Components to Redux Store
Using useSelector and useDispatch Hooks
useSelector extracts data from the store. useDispatch gives you the dispatch function. These two hooks are all you need in most components.
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { addTodo, removeTodo } from './actions';
function TodoList() {
const todos = useSelector((state) => state.todos);
const dispatch = useDispatch();
const handleAddTodo = (text) => {
dispatch(addTodo(text));
};
const handleRemoveTodo = (id) => {
dispatch(removeTodo(id));
};
// Component rendering logic...
}
Middleware and Async Actions
Redux middleware sits between dispatching an action and the reducer processing it. This is where async operations belong.
Using Redux Thunk
Redux Thunk lets action creators return a function instead of an action object. That function receives dispatch and can do async work before dispatching.
Install Redux Thunk:
npm install redux-thunk
Configure the store with middleware:
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './reducers';
const store = createStore(rootReducer, applyMiddleware(thunk));
export default store;
Create an async action creator:
export const fetchTodos = () => {
return async (dispatch) => {
dispatch({ type: 'FETCH_TODOS_REQUEST' });
try {
const response = await fetch('/api/todos');
const data = await response.json();
dispatch({ type: 'FETCH_TODOS_SUCCESS', payload: data });
} catch (error) {
dispatch({ type: 'FETCH_TODOS_FAILURE', payload: error });
}
};
};
Example: Building a Todo Application with Redux
Project Setup
Initialize a new React app:
npx create-react-app redux-todo-app
cd redux-todo-app
npm install redux react-redux redux-thunk
Defining Actions and Action Creators
In src/actions/index.js:
export const ADD_TODO = 'ADD_TODO';
export const REMOVE_TODO = 'REMOVE_TODO';
export const FETCH_TODOS_REQUEST = 'FETCH_TODOS_REQUEST';
export const FETCH_TODOS_SUCCESS = 'FETCH_TODOS_SUCCESS';
export const FETCH_TODOS_FAILURE = 'FETCH_TODOS_FAILURE';
export const addTodo = (text) => ({
type: ADD_TODO,
payload: { text },
});
export const removeTodo = (id) => ({
type: REMOVE_TODO,
payload: { id },
});
export const fetchTodos = () => {
return async (dispatch) => {
dispatch({ type: FETCH_TODOS_REQUEST });
try {
const response = await fetch('/api/todos');
const data = await response.json();
dispatch({ type: FETCH_TODOS_SUCCESS, payload: data });
} catch (error) {
dispatch({ type: FETCH_TODOS_FAILURE, payload: error });
}
};
};
Creating Reducers
In src/reducers/todoReducer.js:
import {
ADD_TODO,
REMOVE_TODO,
FETCH_TODOS_REQUEST,
FETCH_TODOS_SUCCESS,
FETCH_TODOS_FAILURE,
} from '../actions';
const initialState = {
todos: [],
loading: false,
error: null,
};
function todoReducer(state = initialState, action) {
switch (action.type) {
case FETCH_TODOS_REQUEST:
return { ...state, loading: true };
case FETCH_TODOS_SUCCESS:
return { ...state, loading: false, todos: action.payload };
case FETCH_TODOS_FAILURE:
return { ...state, loading: false, error: action.payload };
case ADD_TODO:
const newTodo = {
id: Date.now(),
text: action.payload.text,
};
return { ...state, todos: [...state.todos, newTodo] };
case REMOVE_TODO:
return {
...state,
todos: state.todos.filter((todo) => todo.id !== action.payload.id),
};
default:
return state;
}
}
export default todoReducer;
In src/reducers/index.js:
import { combineReducers } from 'redux';
import todoReducer from './todoReducer';
const rootReducer = combineReducers({
todoState: todoReducer,
});
export default rootReducer;
Configuring the Store
In src/store.js:
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './reducers';
const store = createStore(rootReducer, applyMiddleware(thunk));
export default store;
Connecting Components
In src/App.js:
import React, { useEffect, useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { addTodo, removeTodo, fetchTodos } from './actions';
function App() {
const [input, setInput] = useState('');
const { todos, loading, error } = useSelector((state) => state.todoState);
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchTodos());
}, [dispatch]);
const handleAddTodo = () => {
if (input.trim() !== '') {
dispatch(addTodo(input));
setInput('');
}
};
const handleRemoveTodo = (id) => {
dispatch(removeTodo(id));
};
if (loading) return <p>Loading todos...</p>;
if (error) return <p>Error fetching todos: {error.message}</p>;
return (
<div>
<h1>Todo List</h1>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Add todo..."
/>
<button onClick={handleAddTodo}>Add</button>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{todo.text}{' '}
<button onClick={() => handleRemoveTodo(todo.id)}>Remove</button>
</li>
))}
</ul>
</div>
);
}
export default App;
Adding Async Actions
The fetchTodos action creator above simulates fetching todos from an API. In a real project, replace the fetch URL with your actual API endpoint.
Best Practices with Redux
- Always return new state objects from reducers. Never mutate state in place.
- Normalize your state shape. Flat structures are easier to update than deeply nested ones.
- Keep state serializable. Non-serializable values break time-travel debugging.
- Write selector functions to encapsulate state access. This lets you change the state shape later without updating every component.
- Use
combineReducersto split a large reducer into focused pieces. - Handle async operations in middleware, not in reducers.
Conclusion
Redux enforces a discipline that pays off as applications grow. One store, one direction of data flow, pure reducers: these constraints make bugs reproducible and state changes auditable. The todo app above covers the full cycle from actions through reducers to connected components, plus async fetching via Thunk. That pattern scales directly to larger applications without fundamentally changing.