Redux Fundamentals — Store, Actions and Reducers

Redux is a predictable state container — all application state lives in a single JavaScript object (the store), state can only be updated by dispatching actions (plain objects describing what happened), and a pure reducer function specifies how the state changes. This one-way data flow makes state changes traceable, reproducible, and debuggable with time-travel tooling. Redux Toolkit (RTK) is the official, opinionated way to write Redux — it eliminates the historical boilerplate of action type constants, action creators, and switch-statement reducers, replacing them with concise, modern APIs.

The Redux Data Flow

Redux unidirectional data flow:

Component dispatches action
    ↓
dispatch({ type: "posts/likePost", payload: { postId: 42 } })
    ↓
Reducer receives (currentState, action)
    ↓
Returns new state (immutably)
    ↓
Store updates
    ↓
All subscribed components re-render with new state

Key properties:
  • Single source of truth: one store for all state
  • State is read-only: only actions can change it
  • Changes via pure functions: reducers are predictable and testable
  • Devtools: every action is recorded — replay, time-travel, inspect
Note: Redux Toolkit’s createSlice uses Immer under the hood, which lets you write “mutating” code (state.posts.push(newPost)) that is actually converted to immutable updates behind the scenes. This is a major ergonomic improvement over plain Redux where you had to manually spread every nested object. Despite the appearance of mutation, the actual state is never mutated — Immer intercepts the writes and produces a new state object.
Tip: Install the Redux DevTools browser extension (Chrome/Firefox) before starting with Redux. It shows every dispatched action, the state before and after, and lets you jump back to any previous state (time-travel debugging). This makes Redux debugging dramatically easier than console.log — you can see exactly which action caused a state change and what the state looked like at any point in time. It is free, takes 30 seconds to install, and is one of Redux’s biggest advantages over alternatives.
Warning: Redux is not the right tool for every project. For a simple blog application with one or two developers, Zustand (Chapter 39) provides 90% of Redux’s value with a fraction of the setup. Redux’s strengths become apparent in large teams (shared conventions, enforced patterns), complex update logic (many interconnected state slices), and applications where full auditability of state changes matters (debugging production issues with action logs). Evaluate the tradeoff honestly before adopting Redux.

Installation

npm install @reduxjs/toolkit react-redux

Plain Redux vs Redux Toolkit

// ── Plain Redux (old way) ─────────────────────────────────────────────────────
// Three separate files required: action types, action creators, reducer

// actionTypes.js
const LIKE_POST   = "posts/LIKE_POST";
const UNLIKE_POST = "posts/UNLIKE_POST";

// actions.js
const likePost   = (postId) => ({ type: LIKE_POST,   payload: postId });
const unlikePost = (postId) => ({ type: UNLIKE_POST, payload: postId });

// reducer.js
function postsReducer(state = [], action) {
    switch (action.type) {
        case LIKE_POST:
            return state.map(p =>
                p.id === action.payload
                    ? { ...p, likeCount: p.likeCount + 1, liked: true }
                    : p
            );
        default: return state;
    }
}

// ── Redux Toolkit (modern way) ────────────────────────────────────────────────
// Everything in one createSlice call

import { createSlice } from "@reduxjs/toolkit";

const postsSlice = createSlice({
    name: "posts",
    initialState: { items: [], isLoading: false, error: null },
    reducers: {
        likePost(state, action) {
            const post = state.items.find(p => p.id === action.payload);
            if (post) { post.likeCount += 1; post.liked = true; }
            // Immer makes "mutation" safe — this actually produces a new state
        },
        unlikePost(state, action) {
            const post = state.items.find(p => p.id === action.payload);
            if (post) { post.likeCount = Math.max(0, post.likeCount - 1); post.liked = false; }
        },
    },
});

export const { likePost, unlikePost } = postsSlice.actions;
export default postsSlice.reducer;

Common Mistakes

Mistake 1 — Mutating state outside createSlice (without Immer protection)

❌ Wrong — direct mutation in a regular reducer breaks Redux:

function reducer(state = [], action) {
    state.push(action.payload);   // mutation! Redux won't detect the change
    return state;   // same reference — UI won't update
}

✅ Correct — return new state, or use createSlice (Immer handles it):

function reducer(state = [], action) {
    return [...state, action.payload];   // ✓ new array
}

Mistake 2 — Storing everything in Redux

❌ Wrong — Redux for local UI state (toggle, hover, form values):

// isDropdownOpen belongs in local useState, not global Redux store
dispatch({ type: "ui/openDropdown" });

✅ Correct — Redux for shared global state; useState for local component state.

Quick Reference

Concept RTK API
Create slice (state + reducers) createSlice({ name, initialState, reducers })
Create store configureStore({ reducer: { posts: postsSlice.reducer } })
Read state in component const posts = useSelector(s => s.posts.items)
Dispatch action const dispatch = useDispatch(); dispatch(likePost(42))
Action creator Auto-generated: postsSlice.actions.likePost
Wrap app <Provider store={store}><App /></Provider>

🧠 Test Yourself

Inside a createSlice reducer, you write state.items.push(newItem). This looks like mutation. Does it actually mutate the Redux state?