Redux From the Ground Up (Elementary to Advanced)”

Hi there! I’m Shrijith Venkatrama, founder of Hexmos. Right now, I’m building LiveAPI, a first of its kind tool for helping you automatically index API endpoints across all your repositories. LiveAPI helps you discover, understand and use APIs in large tech infrastructures with ease.

Redux is a predictable state management library for JavaScript apps, especially React. It’s powerful but can feel like a maze for beginners and even seasoned devs. This guide walks you through Redux from the ground up—starting with the basics and scaling to advanced patterns. We’ll keep it hands-on with examples you can actually run. Let’s dive in.

Why Redux? Understanding the Problem It Solves

State management in JavaScript apps can get messy. Imagine a React app where components need to share data—like user info or a shopping cart. Passing props down multiple levels or lifting state up gets clunky fast. Redux centralizes state, making it predictable and easier to debug.

  • Problem: Scattered state across components leads to prop drilling or inconsistent updates.
  • Solution: Redux stores all app state in one place, with strict rules for updates.
  • Use case: Apps with complex state, like e-commerce platforms or real-time dashboards.

For a quick overview of state management challenges, check React’s official docs.

Core Concepts: Actions, Reducers, and Store

Redux revolves around three core pieces: actions, reducers, and the store. Think of actions as messages describing what happened, reducers as functions that update state based on those messages, and the store as the single source of truth for your app’s state.

  • Actions: Plain objects with a type property (and optional payload).
  • Reducers: Pure functions that take the current state and an action, then return a new state.
  • Store: Holds the entire app state and dispatches actions.

Here’s a minimal example of a counter app to show these in action:

// index.js
import { createStore } from 'redux';

// Action creators
const increment = () => ({ type: 'INCREMENT' });
const decrement = () => ({ type: 'DECREMENT' });

// Reducer
const counterReducer = (state = 0, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1;
    case 'DECREMENT':
      return state - 1;
    default:
      return state;
  }
};

// Store
const store = createStore(counterReducer);

// Test it
console.log(store.getState()); // Output: 0
store.dispatch(increment());
console.log(store.getState()); // Output: 1
store.dispatch(decrement());
console.log(store.getState()); // Output: 0

Run this: Save as index.js, install Redux (npm install redux), and run with Node (node index.js). It’s a simple counter, but it shows how actions trigger state changes via reducers.

Setting Up Redux in a React App

Let’s integrate Redux with React. You’ll need redux, react-redux, and a basic React setup. The goal is to manage state in a React app using Redux. Key tools: Provider (to pass the store to components) and useSelector/useDispatch (React-Redux hooks).

Here’s a todo app example:

// index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import App from './App';

// Reducer
const todoReducer = (state = [], action) => {
  switch (action.type) {
    case 'ADD_TODO':
      return [...state, { id: Date.now(), text: action.payload, completed: false }];
    default:
      return state;
  }
};

const store = createStore(todoReducer);

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <Provider store={store}>
    <App />
  </Provider>
);

// App.js
import React, { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';

function App() {
  const [todoText, setTodoText] = useState('');
  const todos = useSelector((state) => state);
  const dispatch = useDispatch();

  const addTodo = () => {
    dispatch({ type: 'ADD_TODO', payload: todoText });
    setTodoText('');
  };

  return (
    <div>
      <input
        type="text"
        value={todoText}
        onChange={(e) => setTodoText(e.target.value)}
      />
      <button onClick={addTodo}>Add Todo</button>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </div>
  );
}

export default App;

Run this: Set up a React app (npx create-react-app my-app), install redux and react-redux, replace src/index.js and src/App.js with the above, and run (npm start). You’ll get a todo app where typing a task and clicking “Add Todo” adds it to the list.

See React-Redux docs for more on setup.

Structuring a Redux Store for Scalability

As apps grow, a single reducer becomes unwieldy. Combine reducers to split state into manageable slices. Each reducer handles a specific part of the state, like todos or users.

Here’s an example combining reducers for a todo app with filters:

// reducers/todos.js
const todosReducer = (state = [], action) => {
  switch (action.type) {
    case 'ADD_TODO':
      return [...state, { id: Date.now(), text: action.payload, completed: false }];
    case 'TOGGLE_TODO':
      return state.map((todo) =>
        todo.id === action.payload ? { ...todo, completed: !todo.completed } : todo
      );
    default:
      return state;
  }
};

// reducers/filters.js
const filtersReducer = (state = 'ALL', action) => {
  switch (action.type) {
    case 'SET_FILTER':
      return action.payload;
    default:
      return state;
  }
};

// reducers/index.js
import { combineReducers } from 'redux';
import todosReducer from './todos';
import filtersReducer from './filters';

export default combineReducers({
  todos: todosReducer,
  filters: filtersReducer,
});

// index.js (updated store)
import { createStore } from 'redux';
import rootReducer from './reducers';

const store = createStore(rootReducer);

// Test it
store.dispatch({ type: 'ADD_TODO', payload: 'Learn Redux' });
console.log(store.getState());
// Output: { todos: [{ id: <timestamp>, text: 'Learn Redux', completed: false }], filters: 'ALL' }

Run this: Organize the reducers in a src/reducers folder, update index.js, and test with Node or a React app. The state is now split into todos and filters, making it easier to manage.

Middleware: Supercharging Redux with Async Logic

Redux reducers are synchronous, but apps often need async operations like API calls. Middleware (like redux-thunk) lets you handle async logic. Here’s how to fetch todos from an API using redux-thunk:

// index.js
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './reducers';

// Action creator with thunk
const fetchTodos = () => async (dispatch) => {
  dispatch({ type: 'FETCH_TODOS_REQUEST' });
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5');
    const todos = await response.json();
    dispatch({ type: 'FETCH_TODOS_SUCCESS', payload: todos });
  } catch (error) {
    dispatch({ type: 'FETCH_TODOS_FAILURE', payload: error.message });
  }
};

// Reducer
const todosReducer = (state = { items: [], loading: false, error: null }, action) => {
  switch (action.type) {
    case 'FETCH_TODOS_REQUEST':
      return { ...state, loading: true, error: null };
    case 'FETCH_TODOS_SUCCESS':
      return { ...state, loading: false, items: action.payload };
    case 'FETCH_TODOS_FAILURE':
      return { ...state, loading: false, error: action.payload };
    default:
      return state;
  }
};

const store = createStore(todosReducer, applyMiddleware(thunk));

// Test it
store.dispatch(fetchTodos());
setTimeout(() => console.log(store.getState()), 2000);
// Output: { items: [{ id: 1, title: "...", ... }], loading: false, error: null }

Run this: Install redux-thunk (npm install redux-thunk), set up the code, and run. It fetches todos from a public API and updates the state. Check Redux Thunk’s GitHub for more.

Debugging Like a Pro: Redux DevTools

Redux DevTools is a game-changer for debugging. It lets you inspect state changes, replay actions, and time-travel through your app’s state. Install the browser extension and add the DevTools enhancer to your store.

// index.js
import { createStore, applyMiddleware } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import thunk from 'redux-thunk';
import rootReducer from './reducers';

const store = createStore(rootReducer, composeWithDevTools(applyMiddleware(thunk)));

// Same todo fetch example as above
store.dispatch(fetchTodos());

Run this: Install the Redux DevTools extension for Chrome/Firefox, add redux-devtools-extension (npm install redux-devtools-extension), and open the DevTools in your browser. You’ll see every action and state change logged.

Advanced Patterns: Normalizing State and Selectors

Large apps often deal with complex data, like nested API responses. Normalizing state (flattening data) makes updates easier. Pair this with selectors (functions to compute derived state) for performance.

Here’s an example normalizing todos and selecting completed ones:

// reducers/todos.js
const todosReducer = (state = { byId: {}, allIds: [] }, action) => {
  switch (action.type) {
    case 'ADD_TODO':
      const id = Date.now();
      return {
        byId: { ...state.byId, [id]: { id, text: action.payload, completed: false } },
        allIds: [...state.allIds, id],
      };
    case 'TOGGLE_TODO':
      return {
        ...state,
        byId: {
          ...state.byId,
          [action.payload]: {
            ...state.byId[action.payload],
            completed: !state.byId[action.payload].completed,
          },
        },
      };
    default:
      return state;
  }
};

// selectors.js
export const getCompletedTodos = (state) =>
  state.allIds
    .map((id) => state.byId[id])
    .filter((todo) => todo.completed);

// index.js
import { createStore } from 'redux';
import todosReducer from './reducers/todos';
import { getCompletedTodos } from './selectors';

const store = createStore(todosReducer);

store.dispatch({ type: 'ADD_TODO', payload: 'Learn Redux' });
store.dispatch({ type: 'ADD_TODO', payload: 'Master Redux' });
store.dispatch({ type: 'TOGGLE_TODO', payload: store.getState().allIds[0] });
console.log(getCompletedTodos(store.getState()));
// Output: [{ id: <timestamp>, text: 'Learn Redux', completed: true }]

Run this: Set up the files and test. The state is normalized (byId and allIds), and the selector grabs completed todos efficiently. Read more on normalization at Redux’s style guide.

Best Practices for Clean Redux Code

To keep Redux maintainable, follow these practices:

Practice Why It Matters Example
Use action creators Encapsulates action logic const addTodo = (text) => ({ type: 'ADD_TODO', payload: text })
Keep reducers pure Predictable state updates No API calls or side effects in reducers
Normalize state Easier to update complex data Store todos as { byId: {}, allIds: [] }
Use selectors Avoid duplicating logic getCompletedTodos(state) for derived data
  • Avoid mutating state in reducers; always return a new state.
  • Use constants for action types to prevent typos.
  • Organize files: Group reducers, actions, and selectors by feature (e.g., todos/ folder).

Where to Go Next with Redux

Redux is a foundation, not the whole house. Once you’re comfortable, explore these:

  • Redux Toolkit: Simplifies Redux with utilities like createSlice. It reduces boilerplate and includes redux-thunk. Start with Redux Toolkit’s quick start.
  • React Query or SWR: For server-state management (API calls), these libraries can complement Redux.
  • TypeScript: Add type safety to your Redux code for larger apps.
  • Testing: Use Jest to test reducers and actions. Write pure functions for easy testing.

Try building a small app, like a task manager with CRUD operations, to solidify your skills. Experiment with Redux Toolkit to see how it streamlines setup. If you hit roadblocks, the Redux community on GitHub or Stack Overflow is super active.

Similar Posts