Redux Core Concepts and Architecture

Understanding the Principles, Patterns, and Philosophy of Redux

What is Redux?

Redux is a predictable state container for JavaScript applications. It helps you write applications that behave consistently, run in different environments (client, server, and native), and are easy to test.

Originally designed for React, Redux can be used with any UI layer or framework, including Vue, Angular, or vanilla JavaScript. It's most commonly used with React, where it complements React's component-based architecture.

When to Use Redux

Redux is most beneficial in the following scenarios:

  • Your application has a large amount of application state needed in many places
  • The app state is updated frequently
  • The logic to update that state may be complex
  • The app has a medium or large-sized codebase with many developers working on it
  • You need to track how state is updated over time

For small applications or simple state needs, alternatives like React's Context API or component-local state might be simpler solutions.

Core Principles of Redux

Redux follows three fundamental principles that shape its architecture and usage:

1. Single Source of Truth

The global state of your application is stored in a single store as a JavaScript object. This makes it easier to debug and inspect the application, enables server-rendering, and simplifies persistence and rehydration.


// Example of a single Redux store
const store = {
  users: {
    currentUser: {
      id: 'u123',
      name: 'Alice Johnson',
      email: 'alice@example.com',
      preferences: {
        theme: 'dark',
        notifications: true
      }
    },
    isLoading: false,
    error: null
  },
  products: {
    items: [
      { id: 'p1', name: 'Laptop', price: 999 },
      { id: 'p2', name: 'Phone', price: 699 }
    ],
    selectedProduct: 'p1',
    filter: 'all',
    isLoading: false
  },
  cart: {
    items: [
      { productId: 'p2', quantity: 1 }
    ],
    isCheckingOut: false
  },
  ui: {
    menuOpen: false,
    darkMode: true,
    currentPage: 'home'
  }
}
        

Real-World Analogy: Library Database

Think of Redux's single store like a library's central database. No matter which librarian you ask or which terminal you use, you'll get the same information about book availability, location, and borrowing status. This prevents the confusion that would arise if different terminals showed different information about the same book.

2. State is Read-Only

The only way to change the state is to emit an action, an object describing what happened. This ensures that neither the views nor the network callbacks write directly to the state, making state changes predictable and traceable.


// Actions are plain JavaScript objects with a type property
const addToCartAction = {
  type: 'cart/addItem',
  payload: {
    productId: 'p1',
    quantity: 1
  }
};

// Dispatching an action is the only way to modify the state
store.dispatch(addToCartAction);
        

Real-World Analogy: Bank Transactions

Imagine if you could modify your bank account balance directly whenever you wanted. This would lead to chaos! Instead, banks require transaction forms (like deposits or withdrawals) to change your balance. These forms are like Redux actions - they document what change should occur, are processed by authorized personnel (reducers), and leave a clear audit trail.

3. Changes are Made with Pure Functions

To specify how the state tree is transformed by actions, you write pure reducers - functions that take the previous state and an action, and return the next state. Reducers must be pure functions, meaning they don't modify the input state, make API calls, or produce side effects.


// A reducer is a pure function that takes state and an action
// and returns a new state without modifying the original
function cartReducer(state = { items: [] }, action) {
  switch (action.type) {
    case 'cart/addItem':
      return {
        ...state,
        items: [
          ...state.items,
          {
            productId: action.payload.productId,
            quantity: action.payload.quantity
          }
        ]
      };
    
    case 'cart/removeItem':
      return {
        ...state,
        items: state.items.filter(item => 
          item.productId !== action.payload.productId
        )
      };
    
    default:
      return state;
  }
}
        

Real-World Analogy: Recipe Book

A reducer is like a recipe in a cookbook. Given the same ingredients (previous state and action), it will always produce the same dish (new state). It doesn't change the original ingredients, and it doesn't depend on anything outside the recipe. This predictability means you can make the same meal consistently, time after time.

The Redux Flow

Redux follows a unidirectional data flow that makes state changes predictable and traceable. Understanding this flow is key to mastering Redux.

flowchart TD UI[UI Components] -->|Dispatch Action| A[Action] A -->|Sent to| R[Reducer] S[Current State] -->|Input to| R R -->|Produces| NS[New State] NS -->|Stored in| ST[Store] ST -->|Notifies| UI classDef component fill:#f9f,stroke:#333,stroke-width:1px; classDef action fill:#ff9,stroke:#333,stroke-width:1px; classDef reducer fill:#9f9,stroke:#333,stroke-width:1px; classDef state fill:#99f,stroke:#333,stroke-width:1px; classDef store fill:#f99,stroke:#333,stroke-width:1px; class UI component; class A action; class R reducer; class S,NS state; class ST store;

Here's how the data flows through Redux:

  1. User Interaction: A user interacts with the application (clicks a button, submits a form, etc.)
  2. Dispatch Action: The interaction triggers the dispatch of an action (a plain JavaScript object describing what happened)
  3. Reducer Processing: The store sends the current state and the action to the reducer function
  4. State Update: The reducer creates a new state based on the old state and the action
  5. Store Update: The store updates its internal state with the new state
  6. UI Notification: The store notifies all connected components about the state change
  7. Re-render: Connected components re-render with the new state

// 1. Define an action type
const ADD_TO_CART = 'cart/addItem';

// 2. Create an action creator
function addToCart(productId, quantity) {
  return {
    type: ADD_TO_CART,
    payload: {
      productId,
      quantity
    }
  };
}

// 3. Define a reducer
function cartReducer(state = { items: [] }, action) {
  switch (action.type) {
    case ADD_TO_CART:
      return {
        ...state,
        items: [
          ...state.items,
          {
            productId: action.payload.productId,
            quantity: action.payload.quantity
          }
        ]
      };
    default:
      return state;
  }
}

// 4. Create a store
const store = Redux.createStore(cartReducer);

// 5. Subscribe to state changes
store.subscribe(() => {
  console.log('New state:', store.getState());
});

// 6. Dispatch an action
store.dispatch(addToCart('p1', 1));

// Console output:
// New state: { items: [{ productId: 'p1', quantity: 1 }] }
        

Real-World Analogy: Restaurant Order System

The Redux flow is like a restaurant ordering system:

  1. Customer (UI): Places an order (dispatches an action)
  2. Waiter (Dispatch): Takes the order to the kitchen
  3. Chef (Reducer): Prepares the food according to the order without changing the original ingredients
  4. Kitchen Manager (Store): Updates the inventory and notifies the waiter
  5. Waiter (Subscribe): Brings the prepared food back to the customer
  6. Customer (UI): Receives the food and enjoys it

Just like in a well-run restaurant where orders follow a clear path, Redux ensures that state changes follow a consistent, predictable flow.

Core Components of Redux

Actions

Actions are plain JavaScript objects that represent an intention to change the state. They must have a type property to indicate the type of action being performed. Actions are typically created by functions called action creators.


// Action type constant
const UPDATE_USER = 'user/update';

// Action creator
function updateUser(userData) {
  return {
    type: UPDATE_USER,
    payload: userData
  };
}

// Dispatching an action
dispatch(updateUser({
  name: 'Alice',
  email: 'alice@example.com'
}));
        

Best practices for actions:

Reducers

Reducers are pure functions that specify how the application's state changes in response to actions. A reducer takes the current state and an action, and returns a new state.


function userReducer(state = initialUserState, action) {
  switch (action.type) {
    case 'user/update':
      return {
        ...state,
        profile: {
          ...state.profile,
          ...action.payload
        },
        lastUpdated: new Date().toISOString()
      };
    
    case 'user/logout':
      return initialUserState;
    
    default:
      return state;
  }
}
        

Key characteristics of reducers:

Store

The store is the object that brings actions and reducers together. It holds the application state, allows state to be updated via dispatch(action), and registers listeners via subscribe(listener).


import { createStore } from 'redux';
import rootReducer from './reducers';

const store = createStore(
  rootReducer,
  // Optional initial state
  initialState,
  // Optional enhancers like middleware
  applyMiddleware(...middleware)
);

// Methods provided by the store:
store.getState();      // Returns the current state
store.dispatch(action); // Dispatches an action
store.subscribe(listener); // Registers a state change listener
        

Important store concepts:

Combining Reducers

As applications grow, the state becomes more complex. Redux allows you to split your reducers into separate functions, each managing a slice of the state, and then combine them with combineReducers.

flowchart TD MainStore[Store] --> CombinedReducer[Combined Reducer] CombinedReducer --> UserReducer[User Reducer] CombinedReducer --> ProductReducer[Product Reducer] CombinedReducer --> CartReducer[Cart Reducer] CombinedReducer --> UIReducer[UI Reducer] UserReducer --> UserState[User State] ProductReducer --> ProductState[Product State] CartReducer --> CartState[Cart State] UIReducer --> UIState[UI State] UserState & ProductState & CartState & UIState --> CompleteState[Complete Application State] classDef reducer fill:#9f9,stroke:#333,stroke-width:1px; classDef state fill:#99f,stroke:#333,stroke-width:1px; classDef store fill:#f99,stroke:#333,stroke-width:1px; class MainStore,CompleteState store; class CombinedReducer,UserReducer,ProductReducer,CartReducer,UIReducer reducer; class UserState,ProductState,CartState,UIState state;

import { combineReducers } from 'redux';

// Reducer for user-related state
function userReducer(state = {}, action) {
  // Handle user-related actions
  switch (action.type) {
    case 'user/login':
    case 'user/update':
    case 'user/logout':
      // Return new user state
    default:
      return state;
  }
}

// Reducer for product-related state
function productReducer(state = { items: [] }, action) {
  // Handle product-related actions
  switch (action.type) {
    case 'products/fetch':
    case 'products/filter':
    case 'products/select':
      // Return new product state
    default:
      return state;
  }
}

// Reducer for cart-related state
function cartReducer(state = { items: [] }, action) {
  // Handle cart-related actions
  switch (action.type) {
    case 'cart/addItem':
    case 'cart/removeItem':
    case 'cart/updateQuantity':
      // Return new cart state
    default:
      return state;
  }
}

// Reducer for UI-related state
function uiReducer(state = {}, action) {
  // Handle UI-related actions
  switch (action.type) {
    case 'ui/toggleMenu':
    case 'ui/setTheme':
    case 'ui/showModal':
      // Return new UI state
    default:
      return state;
  }
}

// Combine all reducers into a single root reducer
const rootReducer = combineReducers({
  user: userReducer,
  products: productReducer,
  cart: cartReducer,
  ui: uiReducer
});

// Create the store with the combined reducer
const store = createStore(rootReducer);

// The state shape will match the reducer structure:
/*
{
  user: { ... }, // Managed by userReducer
  products: { ... }, // Managed by productReducer
  cart: { ... }, // Managed by cartReducer
  ui: { ... } // Managed by uiReducer
}
*/
        

This approach has several benefits:

Real-World Analogy: Government Departments

Combining reducers is like how different government departments handle different concerns:

  • The Treasury Department handles financial matters
  • The Defense Department handles military concerns
  • The Justice Department handles legal issues
  • The Education Department handles school policies

Each department specializes in a specific domain, with its own internal processes, but together they form a complete government. Similarly, each reducer manages its own slice of state, but together they form the complete application state.

Redux Middleware

Middleware provides a way to extend Redux with custom functionality. It sits between dispatching an action and the moment it reaches the reducer, allowing for side effects, async operations, logging, crash reporting, and more.

flowchart LR Dispatch[Dispatch] --> M1[Logger Middleware] M1 --> M2[API Middleware] M2 --> M3[Error Middleware] M3 --> Reducer[Reducer] classDef dispatch fill:#ff9,stroke:#333,stroke-width:1px; classDef middleware fill:#9ff,stroke:#333,stroke-width:1px; classDef reducer fill:#9f9,stroke:#333,stroke-width:1px; class Dispatch dispatch; class M1,M2,M3 middleware; class Reducer reducer;

// Simple logging middleware
const loggerMiddleware = store => next => action => {
  console.log('Dispatching:', action);
  const result = next(action);
  console.log('Next state:', store.getState());
  return result;
};

// Simple API middleware
const apiMiddleware = store => next => action => {
  // Check if the action has an API call
  if (!action.api) {
    return next(action);
  }
  
  // Dispatch pending action
  store.dispatch({ type: `${action.type}_PENDING` });
  
  // Make the API call
  return fetch(action.api.url, action.api.options)
    .then(response => response.json())
    .then(data => {
      // Dispatch success action
      store.dispatch({
        type: `${action.type}_SUCCESS`,
        payload: data
      });
      return data;
    })
    .catch(error => {
      // Dispatch error action
      store.dispatch({
        type: `${action.type}_ERROR`,
        error: error.message
      });
      throw error;
    });
};

// Apply middleware to the store
import { createStore, applyMiddleware } from 'redux';
import rootReducer from './reducers';

const store = createStore(
  rootReducer,
  applyMiddleware(
    loggerMiddleware,
    apiMiddleware
  )
);
        

Common use cases for middleware:

Popular Redux middleware libraries:

Redux vs. Context API

With the introduction of React's Context API and hooks, many developers wonder when to use Redux versus React's built-in state management. Here's a comparison:

Feature Redux Context API
Learning Curve Steeper, more concepts to learn Simpler, fewer concepts
Boilerplate More boilerplate code Less boilerplate code
Middleware Built-in support for middleware Requires custom implementation
DevTools Excellent developer tools Limited debugging tools
Performance Optimized for large applications Can cause re-renders in large apps
Community Large ecosystem of libraries and tools Newer, smaller ecosystem

When to choose Redux:

When to choose Context:

Practice Activities

Activity 1: Create a Basic Redux Store

Implement a simple counter application using Redux:

  1. Define action types for incrementing, decrementing, and resetting a counter
  2. Create action creators for each action type
  3. Write a reducer to handle these actions
  4. Create a store with the reducer
  5. Subscribe to store changes and log the state
  6. Dispatch various actions and observe the state changes

Activity 2: Combine Multiple Reducers

Extend the counter application to manage multiple features:

  1. Create a counter reducer (from Activity 1)
  2. Add a new todos reducer for managing a list of todo items
  3. Add a user reducer for managing user preferences
  4. Combine these reducers using combineReducers
  5. Create a store with the combined reducer
  6. Dispatch various actions to each slice of state
  7. Observe how the state structure matches the reducer structure

Activity 3: Implement a Custom Middleware

Create a custom middleware for the Redux store:

  1. Write a logging middleware that logs actions and state changes
  2. Create a "delay" middleware that adds a time delay to certain actions
  3. Implement a "validator" middleware that checks actions for required fields
  4. Apply these middleware to the store created in Activity 2
  5. Dispatch various actions and observe the middleware in action

Summary

Further Resources