Share
1

Redux in Practice: createStore, Reducers, Actions, and Subscriptions

by ObserverPoint · July 9, 2025

You’ve grasped the “why” behind Redux and its core principles. Now, let’s transition from theory to practice and dive into how to set up and interact with a Redux store in a real-world application, especially within a React ecosystem. Understanding the mechanics of `createStore`, combining multiple reducers, dispatching actions to trigger state changes, and subscribing to those changes is fundamental to building predictable and scalable React applications with Redux.

While modern Redux development heavily favors Redux Toolkit (which simplifies much of this boilerplate), knowing the underlying concepts is crucial for debugging, optimizing, and extending your Redux setup. This article will guide you through the manual steps of initializing the store, managing different state slices, and orchestrating the data flow.


The Redux Store: Initializing with `createStore`

The Redux store is the central repository that holds your application’s entire state. You create it using the `createStore` function from the `redux` library. The `createStore` function takes a reducer (or a combined reducer) as its first argument and optionally an initial state and an enhancer (e.g., for middleware or DevTools) as subsequent arguments.

import { createStore } from 'redux';

// A simple counter reducer
function counterReducer(state = 0, action: { type: string }) {
    switch (action.type) {
        case 'INCREMENT':
            return state + 1;
        case 'DECREMENT':
            return state - 1;
        default:
            return state;
    }
}

// Create the Redux store, passing your root reducer
const store = createStore(counterReducer);

console.log("Initial state:", store.getState()); // Output: Initial state: 0
    

In this basic example, `counterReducer` is a single reducer. For most real-world applications, your state is more complex and needs to be split into multiple “slices,” each managed by its own reducer. This leads us to the next crucial concept: combining reducers.

Note: For new projects, it’s highly recommended to use `configureStore` from Redux Toolkit instead of `createStore`, as it simplifies setup, includes Redux DevTools integration, and handles common configurations automatically. However, `createStore` remains foundational to understand the core mechanism.


Combining Reducers: Managing Complex State

As your application grows, its state will become more complex. Instead of managing all state in a single giant reducer, Redux encourages you to split your state into smaller, independent “slices,” with each slice managed by its own reducer. The `combineReducers` utility from `redux` helps you combine these individual reducers into a single “root reducer” that `createStore` can understand.

Each key in the object you pass to `combineReducers` will correspond to a key in your overall state tree, and its value will be the reducer function responsible for managing that slice of state.

import { createStore, combineReducers } from 'redux';

// --- User Reducer ---
interface UserState {
    name: string | null;
    isAuthenticated: boolean;
}

const initialUserState: UserState = {
    name: null,
    isAuthenticated: false
};

function userReducer(state: UserState = initialUserState, action: { type: string; payload?: any }): UserState {
    switch (action.type) {
        case 'LOGIN':
            return { ...state, name: action.payload.name, isAuthenticated: true };
        case 'LOGOUT':
            return { ...state, name: null, isAuthenticated: false };
        default:
            return state;
    }
}

// --- Product Reducer ---
interface Product {
    id: number;
    name: string;
    price: number;
}

type ProductsState = Product[];

const initialProductsState: ProductsState = [];

function productsReducer(state: ProductsState = initialProductsState, action: { type: string; payload?: any }): ProductsState {
    switch (action.type) {
        case 'ADD_PRODUCT':
            return [...state, action.payload.product];
        case 'REMOVE_PRODUCT':
            return state.filter(product => product.id !== action.payload.id);
        default:
            return state;
    }
}

// --- Combine all reducers into a single root reducer ---
const rootReducer = combineReducers({
    user: userReducer,      // user state will be managed by userReducer
    products: productsReducer // products state will be managed by productsReducer
});

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

console.log("Initial combined state:", store.getState());
/*
Output:
Initial combined state: {
  user: { name: null, isAuthenticated: false },
  products: []
}
*/
    

This pattern allows for clear separation of concerns, making your reducers smaller, more manageable, and easier to test. When an action is dispatched, `combineReducers` will pass that action to *all* child reducers, and each reducer will decide if and how to update its specific slice of the state.


Dispatching Actions: Triggering State Changes

Once you have a Redux store, the only way to change its state is by calling the `store.dispatch(action)` method. An action is a plain JavaScript object that describes what happened. It must have a `type` property (a string constant, conventionally uppercase with underscores) and can contain any other data necessary in its `payload`.

// Assume 'store' is created as in the "Combining Reducers" example

// Action Types (often defined as constants to avoid typos)
const LOGIN_USER = 'LOGIN_USER';
const LOGOUT_USER = 'LOGOUT_USER';
const ADD_PRODUCT = 'ADD_PRODUCT';

// Action Creators (functions that create and return action objects)
const loginUser = (name: string) => ({
    type: LOGIN_USER,
    payload: { name }
});

const logoutUser = () => ({
    type: LOGOUT_USER
});

const addProduct = (product: { id: number; name: string; price: number }) => ({
    type: ADD_PRODUCT,
    payload: { product }
});

// --- Dispatching Actions ---

console.log("State before login:", store.getState());
store.dispatch(loginUser("Alice"));
console.log("State after login:", store.getState());
/*
Output:
State before login: { user: { name: null, isAuthenticated: false }, products: [] }
State after login: { user: { name: 'Alice', isAuthenticated: true }, products: [] }
*/

console.log("State before adding product:", store.getState());
store.dispatch(addProduct({ id: 101, name: "Laptop", price: 1500 }));
console.log("State after adding product:", store.getState());
/*
Output:
State before adding product: { user: { name: 'Alice', isAuthenticated: true }, products: [] }
State after adding product: { user: { name: 'Alice', isAuthenticated: true }, products: [ { id: 101, name: 'Laptop', price: 1500 } ] }
*/

store.dispatch(logoutUser());
console.log("State after logout:", store.getState());
/*
Output:
State after logout: { user: { name: null, isAuthenticated: false }, products: [ { id: 101, name: 'Laptop', price: 1500 } ] }
*/
    

Each time `dispatch` is called, Redux runs the root reducer with the current state and the dispatched action. The reducer computes the new state, and the store updates itself. This explicit, single-point-of-entry for state changes is what makes Redux state predictable and traceable, especially when combined with Redux DevTools.

In React applications using `react-redux`, you’ll typically use the `useDispatch` hook to get the `dispatch` function and then call it from your components.


Subscribing to the Store: Responding to State Changes

Components (or any part of your application) that need to react to state changes in the Redux store can “subscribe” to it. The `store.subscribe(listener)` method takes a callback function (`listener`) that will be executed every time an action is dispatched and the state tree is potentially updated.

The `subscribe` method returns an `unsubscribe` function, which you can call to stop listening to state changes. This is important to prevent memory leaks, especially in React components where subscriptions should be cleaned up when the component unmounts (e.g., using `useEffect`).

import { createStore, combineReducers } from 'redux';

// --- (Reducers and store setup from previous examples) ---
// For brevity, assuming userReducer and productsReducer are defined
// and rootReducer is combined from them.
const initialUserState = { name: null, isAuthenticated: false };
const initialProductsState: Product[] = [];
function userReducer(state: any = initialUserState, action: any) { /* ... */ return state; }
function productsReducer(state: any = initialProductsState, action: any) { /* ... */ return state; }
const rootReducer = combineReducers({ user: userReducer, products: productsReducer });
const store = createStore(rootReducer);

const LOGIN_USER = 'LOGIN_USER';
const loginUser = (name: string) => ({ type: LOGIN_USER, payload: { name } });
const LOGOUT_USER = 'LOGOUT_USER';
const logoutUser = () => ({ type: LOGOUT_USER });
const ADD_PRODUCT = 'ADD_PRODUCT';
const addProduct = (product: { id: number; name: string; price: number }) => ({ type: ADD_PRODUCT, payload: { product } });

// --- Subscribing to the Store ---

let previousUserStatus = store.getState().user.isAuthenticated;

// Subscribe a listener function
const unsubscribe = store.subscribe(() => {
    const currentState = store.getState();
    console.log("Current state:", currentState);

    // Example of reacting to specific changes:
    const currentUserStatus = currentState.user.isAuthenticated;
    if (currentUserStatus !== previousUserStatus) {
        console.log(`User authentication status changed to: ${currentUserStatus}`);
        previousUserStatus = currentUserStatus;
    }
});

console.log("Subscribed to store changes.");

// Dispatch some actions to see the listener in action
store.dispatch(loginUser("Bob"));
store.dispatch(addProduct({ id: 202, name: "Monitor", price: 300 }));
store.dispatch(logoutUser());

// When done, unsubscribe to prevent memory leaks
unsubscribe();
console.log("Unsubscribed from store changes.");

// This dispatch will NOT trigger the listener anymore
store.dispatch(loginUser("Charlie"));
    

In a React application using `react-redux`, you typically don’t use `store.subscribe` directly in your components. Instead, the `useSelector` hook efficiently subscribes your component to specific parts of the Redux state, automatically handling subscriptions and unsubscriptions, and re-rendering only when the selected state changes.

Understanding `createStore`, `combineReducers`, `dispatch`, and `subscribe` provides a solid foundation for grasping how Redux works internally, even when leveraging the abstractions provided by Redux Toolkit and `react-redux` for a smoother development experience.


References

You may also like