Mastering Redux Core: The Complete Guide to State Management with Plain JavaScript

Mastering Redux Core: The Complete Guide to State Management with Plain JavaScript

Managing state can become quite a headache as your app grows in complexity. You start with a few variables, and before you know it, you’re juggling a mountain of state spread across different parts of your app. Redux steps in to provide a structured and predictable way to manage your state.

In this guide, we’ll walk through the core principles of Redux using pure JavaScript examples. No React, no external libraries—just Redux as it was originally designed. By the end, you’ll understand how Redux works at a deep level, and you'll be ready to apply it in any context.

But before we dive in, let’s start by setting up a Node.js project and installing Redux. This way, you can follow along with the examples.


Setting Up Your Node.js Project and Installing Redux

Before you begin coding with Redux, you’ll need a project to work with. Let’s quickly go over how to set up a Node.js project and install Redux.

Step 1: Initialize a Node.js Project

If you don't already have Node.js installed, go ahead and download and install it.

Once you have Node.js, follow these steps to create a new project:

  1. Open your terminal or command prompt.

  2. Navigate to the directory where you want your project to live.

  3. Run the following command to initialize a new Node.js project:

npm init -y

This will create a basic package.json file with the default settings.

Step 2: Install Redux

With your project initialized, it's time to install Redux. You can do this by running the following command:

npm install redux

This will install Redux in your project and add it as a dependency in your package.json file.

After that, you're all set! You now have a Node.js project with Redux installed and ready to go.


What is Redux?

At its core, Redux is a predictable state container for JavaScript applications. Redux helps you manage the state of your entire app from a single, centralized location.

Let’s break it down:

  1. Centralized: All the state in your application lives in a single object, called the store.

  2. Predictable: Redux enforces a strict pattern for updating state, so you always know how your app’s state is changing.

  3. State container: Redux is agnostic about your app’s UI layer. You can use it with any framework, or even with plain JavaScript.

Why Use Redux?

In small applications, using local state works fine, but as apps grow, managing state across multiple components can get messy. Redux helps by providing:

  • A single source of truth: All your state lives in one place.

  • Consistency: Redux enforces a structured way of managing state, making changes predictable and easy to debug.

  • Debugging tools: Redux comes with powerful developer tools, including time-travel debugging (a fun feature where you can replay state changes step by step).

Redux Toolkit is the recommended way to use Redux today, as it simplifies much of the boilerplate involved in setting up Redux. However, in this guide, we’ll focus on vanilla Redux to understand the core concepts thoroughly.


A Quick Reminder: Don’t Worry if It’s Not Clear at First!

Before we dive into the core concepts, it’s important to remember: Redux is not something you’ll fully grasp the first time around. If this is your first time learning Redux, it may feel a little tricky to get your head around. But don’t worry—just follow along, try to complete the tutorial, and by the end, things will start making sense.

Also, Redux is something you may need to review multiple times to fully understand. It’s okay if you don’t get everything immediately. Keep practicing, and with repetition, it will become second nature!


Redux Core Concepts

Redux is made up of five main pieces:

  1. Store – The central state container.

  2. Actions – The events that describe what happened in the app.

  3. Reducers – Functions that determine how the state changes based on the actions.

  4. Dispatch – The method to send actions to the reducer.

  5. Selectors – Functions to retrieve specific pieces of state.

Let’s break these down one by one.

1. Store: The Central State

The store is the heart of your Redux application. It holds the entire state of your app in one place, and it’s the only source of truth for the current state.

How to Create a Redux Store

To create a store, you use the createStore() function, and pass it a reducer that will manage how the state changes:

const { createStore } = require('redux');

// Initial state
const initialState = {
    count: 0
};

// Reducer function
const counterReducer = (state = initialState, action) => {
    switch (action.type) {
        case 'INCREMENT':
            return {
                ...state,
                count: state.count + 1
            };
        case 'DECREMENT':
            return {
                ...state,
                count: state.count - 1
            };
        default:
            return state; // Make sure to return state in the default case
    }
};

// Create the store
const store = createStore(counterReducer);

console.log(store.getState()); // { count: 0 }

Here’s what’s happening:

  • We define an initial state: { count: 0 }.

  • We create a reducer function (counterReducer) to handle different actions that update the state.

  • We call createStore() and pass it the reducer, which returns our store.

2. Actions: The "What Happened"

In Redux, actions are plain JavaScript objects that describe what happened in your application. An action has a type property that tells Redux what kind of event just occurred.

How to Create Actions

Here are a couple of simple actions:

const incrementAction = {
  type: 'INCREMENT'
};

const decrementAction = {
  type: 'DECREMENT'
};

These actions describe two things that could happen: incrementing or decrementing a counter.

Actions can also carry additional information, or payload, like so:

const addTaskAction = {
  type: 'ADD_TASK',
  payload: { task: 'Learn Redux' }
};

Here, the action is passing extra data (task) to the reducer, which will use it to update the state.

Full code at this point:

const { createStore } = require('redux');

// Initial state
const initialState = {
    count: 0
};

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

// Create the store
const store = createStore(counterReducer);

console.log(store.getState()); // { count: 0 }

// Actions
const incrementAction = { type: 'INCREMENT' };
const decrementAction = { type: 'DECREMENT' };

const addTaskAction = {
  type: 'ADD_TASK',
  payload: { task: 'Learn Redux' }
};

3. Reducers: The How of State Changes

A reducer is a pure function that takes the current state and an action, and returns a new state. It’s the only part of Redux that’s allowed to update the state. Reducers must be pure functions, meaning they:

  • Do not modify the original state.

  • Return a new object representing the updated state.

Writing a Reducer

Let’s revisit the example from before:

const initialState = { count: 0 };

const counterReducer = (state = initialState, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return {
        ...state,
        count: state.count + 1
      };
    case 'DECREMENT':
      return {
        ...state,
        count: state.count - 1
      };
    default:
      return state;
  }
};
  • switch statement: The reducer listens for different action types (INCREMENT, DECREMENT).

  • Immutable updates: The reducer returns a new object using the spread operator (...state), ensuring that the original state is not mutated.

Full code at this point:

const { createStore } = require('redux');

// Initial state
const initialState = {
    count: 0
};

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

// Create the store
const store = createStore(counterReducer);

console.log(store.getState()); // { count: 0 }

// Actions
const incrementAction = { type: 'INCREMENT' };
const decrementAction = { type: 'DECREMENT' };

const addTaskAction = {
  type: 'ADD_TASK',
  payload: { task: 'Learn Redux' }
};

4. Dispatch: Triggering State Changes

The dispatch function is how you trigger an action to be sent to the store. When an action is dispatched, Redux will pass it to the reducer to update the state.

Dispatching an Action

store.dispatch({ type: 'INCREMENT' });
console.log(store.getState()); // { count: 1 }

store.dispatch({ type: 'DECREMENT' });
console.log(store.getState()); // { count: 0 }

Each time we call store.dispatch(), Redux passes the action to the reducer, which calculates the new state.

Subscribing to Store Changes

While dispatching actions updates the state, you might want to know when the state changes so that you can update your UI or perform side effects like logging.

This is where store.subscribe() comes in. It allows you to listen for any changes in the state. Each time the state changes (when an action is dispatched and the reducer processes it), the function inside subscribe() gets called.

Here’s how you can subscribe to the store:

const unsubscribe = store.subscribe(() => {
  console.log("State changed:", store.getState());
});

Now, whenever you dispatch an action, the subscribe() function will log the new state:

store.dispatch({ type: 'INCREMENT' }); // State changed: { count: 1 }
store.dispatch({ type: 'DECREMENT' }); // State changed: { count: 0 }

Unsubscribing from the Store

You can also unsubscribe from the store by calling the function returned

by store.subscribe(). For example, if you no longer need to listen to state changes, simply call unsubscribe():

unsubscribe();

This will stop the listener from being notified when the state changes.

Full code at this point:

const { createStore } = require('redux');

// Initial state
const initialState = {
    count: 0
};

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

// Create the store
const store = createStore(counterReducer);

console.log(store.getState()); // { count: 0 }

// Subscribe to the store
const unsubscribe = store.subscribe(() => {
  console.log("State changed:", store.getState());
});

// Actions
const incrementAction = { type: 'INCREMENT' };
const decrementAction = { type: 'DECREMENT' };

// Dispatch actions
store.dispatch(incrementAction);
store.dispatch(decrementAction);

// Unsubscribe
unsubscribe();

5. Selectors: Picking Out State

Selectors are functions that help you extract specific data from the Redux store. Instead of digging into the state directly in various parts of your app, you use selectors to encapsulate the logic for accessing the state.

How to Create a Selector

const getCount = (state) => state.count;

console.log(getCount(store.getState())); // 0

Selectors are especially useful when the state structure becomes complex. By encapsulating the access logic in a function, you only need to update the selector if the state structure changes, rather than refactoring every component that uses the state.

Full code at this point:

const { createStore } = require('redux');

// Initial state
const initialState = {
    count: 0
};

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

// Create the store
const store = createStore(counterReducer);

console.log(store.getState()); // { count: 0 }

// Subscribe to the store
const unsubscribe = store.subscribe(() => {
  console.log("State changed:", store.getState());
});

// Actions
const incrementAction = { type: 'INCREMENT' };
const decrementAction = { type: 'DECREMENT' };

// Dispatch actions
store.dispatch(incrementAction);
store.dispatch(decrementAction);

// Selector
const getCount = (state) => state.count;

console.log(getCount(store.getState())); // 0

// Unsubscribe
unsubscribe();

Redux Data Flow

One of the best things about Redux is its predictable data flow. The data flow in Redux follows a strict cycle that is easy to understand and debug.

5 Simple Steps of Redux Data Flow

  1. State: The store holds the entire state of your application.

  2. Action: An action is dispatched when something happens (e.g., a user clicks a button).

  3. Reducer: The action is sent to the reducer, which calculates the next state based on the current state and the action.

  4. New State: The store updates with the new state returned by the reducer.

  5. UI Updates: If this were a UI app, the components would re-render with the new state.

Example of Redux Data Flow

Let’s go step-by-step with an example:

Step 1: Initial State

const initialState = { count: 0 };

We start with a simple initial state where count is 0.

Step 2: Define a Reducer

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

The reducer handles two actions: increment and decrement, returning the updated state based on the action.

Step 3: Create the Store

const store = createStore(counterReducer);

We create the Redux store, passing in the reducer.

Step 4: Dispatch Actions

store.dispatch({ type: 'INCREMENT' });
console.log(store.getState()); // { count: 1 }

store.dispatch({ type: 'DECREMENT' });
console.log(store.getState()); // { count: 0 }

By dispatching actions, we trigger state changes. The reducer processes the actions and returns the updated state.


Why Redux Toolkit is the Way Forward

While learning vanilla Redux helps you understand the core concepts, modern Redux applications are better off using Redux Toolkit. Redux Toolkit simplifies much of the setup and reduces the amount of boilerplate code you need to write. It includes features like:

  • Simpler reducers using createSlice().

  • Automatic immutability with Immer.

  • Built-in best practices for working with Redux.

In most cases, using Redux Toolkit is the best way to manage state in modern applications, making your code more efficient and easier to maintain.


Conclusion

Redux gives you a clear, predictable way to manage state in JavaScript applications. By centralizing all your state and enforcing a strict pattern for state updates, Redux makes it easier to build complex applications that are easy to reason about and debug.

Remember, the flow of Redux is simple:

  1. The store holds your state.

  2. You dispatch actions to signal what happened.

  3. Reducers handle these actions and return the updated state.

  4. Selectors let you extract the pieces of state you need.

Once you understand these core principles, you’ll be ready to take on any state management challenge! So go ahead, explore, and have fun with Redux!