Asher Cohen
Back to posts

Redux Fundamentals: State Management for JavaScript Apps

Complete guide to Redux — store, actions, reducers, middleware, normalized state, and modern best practices

Introduction

Redux is a predictable state container for JavaScript applications. It helps you manage global state in a single store, making state changes predictable and debuggable. While Redux Toolkit simplifies modern Redux, understanding the fundamentals is essential.

Core Concepts

The Three Principles

  1. Single source of truth — One store for the entire app
  2. State is read-only — Only changed by dispatching actions
  3. Changes are pure functions — Reducers take state + action → new state

The Data Flow

Action → Dispatch → Reducer → Store → UI (re-render)
// 1. Create store
import { createStore } from 'redux';

// 2. Define reducer
function counterReducer(state = { count: 0 }, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 };
    case 'DECREMENT':
      return { count: state.count - 1 };
    default:
      return state;
  }
}

// 3. Create store with reducer
const store = createStore(counterReducer);

// 4. Dispatch actions
store.dispatch({ type: 'INCREMENT' });
store.dispatch({ type: 'INCREMENT' });
console.log(store.getState()); // { count: 2 }

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

Actions

Actions are plain objects describing what happened:

// Action with type only
{ type: 'INCREMENT' }

// Action with payload
{ type: 'ADD_TODO', payload: { text: 'Learn Redux' } }

// Action creator functions
function addTodo(text) {
  return { type: 'ADD_TODO', payload: { text } };
}

// Async action with Redux Thunk
function fetchUser(id) {
  return async (dispatch) => {
    dispatch({ type: 'FETCH_USER_REQUEST' });
    try {
      const user = await api.fetchUser(id);
      dispatch({ type: 'FETCH_USER_SUCCESS', payload: user });
    } catch (error) {
      dispatch({ type: 'FETCH_USER_FAILURE', error: error.message });
    }
  };
}

Reducers

Reducers specify how state changes in response to actions:

const initialState = {
  items: [],
  filter: 'all',
  loading: false
};

function todosReducer(state = initialState, action) {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        items: [...state.items, { id: Date.now(), text: action.payload, done: false }]
      };
    
    case 'TOGGLE_TODO':
      return {
        ...state,
        items: state.items.map(item =>
          item.id === action.payload ? { ...item, done: !item.done } : item
        )
      };
    
    case 'REMOVE_TODO':
      return {
        ...state,
        items: state.items.filter(item => item.id !== action.payload)
      };
    
    case 'SET_FILTER':
      return { ...state, filter: action.payload };
    
    default:
      return state;
  }
}

Reducer rules:

  • Never mutate state — always return new objects
  • Return the current state for unknown actions
  • Keep reducers pure — no side effects

Store

import { createStore, combineReducers, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';

// Combine multiple reducers
const rootReducer = combineReducers({
  todos: todosReducer,
  filter: filterReducer,
  user: userReducer
});

// Create store with middleware
const store = createStore(
  rootReducer,
  applyMiddleware(thunk)
);

// Access state
store.getState();

// Dispatch actions
store.dispatch(addTodo('Learn Redux'));

// Subscribe to changes
const unsubscribe = store.subscribe(() => {
  console.log(store.getState());
});

// Unsubscribe when done
unsubscribe();

Middleware

Middleware extends Redux with custom functionality:

// Logger middleware
const logger = (store) => (next) => (action) => {
  console.log('Before:', store.getState());
  console.log('Action:', action);
  const result = next(action);
  console.log('After:', store.getState());
  return result;
};

// Error handling middleware
const errorHandler = (store) => (next) => (action) => {
  try {
    return next(action);
  } catch (error) {
    console.error('Redux error:', error);
    store.dispatch({ type: 'ERROR_OCCURRED', error: error.message });
  }
};

Normalizing State

Store data in a normalized shape for efficient updates:

// ❌ Nested state — hard to update
const state = {
  posts: [
    {
      id: 1,
      title: 'Post 1',
      author: { id: 1, name: 'Alice' },
      comments: [
        { id: 1, text: 'Great!', author: { id: 2, name: 'Bob' } }
      ]
    }
  ]
};

// ✅ Normalized state — easy to update
const state = {
  posts: {
    byId: {
      1: { id: 1, title: 'Post 1', authorId: 1, commentIds: [1] }
    },
    allIds: [1]
  },
  users: {
    byId: {
      1: { id: 1, name: 'Alice' },
      2: { id: 2, name: 'Bob' }
    },
    allIds: [1, 2]
  },
  comments: {
    byId: {
      1: { id: 1, text: 'Great!', authorId: 2, postId: 1 }
    },
    allIds: [1]
  }
};

// Update a user once — reflected everywhere
function usersReducer(state, action) {
  switch (action.type) {
    case 'UPDATE_USER':
      return {
        ...state,
        byId: {
          ...state.byId,
          [action.payload.id]: { ...state.byId[action.payload.id], ...action.payload }
        }
      };
  }
}

Selectors

Encapsulate state access logic:

// Basic selectors
const getTodos = (state) => state.todos.items;
const getFilter = (state) => state.todos.filter;

// Derived selectors
const getFilteredTodos = (state) => {
  const todos = getTodos(state);
  const filter = getFilter(state);
  
  switch (filter) {
    case 'active': return todos.filter(t => !t.done);
    case 'done': return todos.filter(t => t.done);
    default: return todos;
  }
};

// Memoized selectors with reselect
import { createSelector } from 'reselect';

const getFilteredTodos = createSelector(
  [getTodos, getFilter],
  (todos, filter) => {
    switch (filter) {
      case 'active': return todos.filter(t => !t.done);
      case 'done': return todos.filter(t => t.done);
      default: return todos;
    }
  }
);

React-Redux Integration

import { Provider, useSelector, useDispatch } from 'react-redux';

// Wrap app with Provider
function App() {
  return (
    <Provider store={store}>
      <TodoList />
    </Provider>
  );
}

// Use hooks in components
function TodoList() {
  const todos = useSelector(state => state.todos.items);
  const dispatch = useDispatch();
  
  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id} onClick={() => dispatch(toggleTodo(todo.id))}>
          {todo.text}
        </li>
      ))}
    </ul>
  );
}

Best Practices

Do's

// ✅ Use Redux Toolkit for new projects
import { configureStore, createSlice } from '@reduxjs/toolkit';

// ✅ Normalize relational data
// ✅ Use selectors for state access
// ✅ Keep reducers pure
// ✅ Use middleware for side effects
// ✅ Dispatch actions, never mutate state directly

Don'ts

// ❌ Don't put everything in Redux
// Local state belongs in components

// ❌ Don't mutate state in reducers
state.items.push(newItem); // Wrong!

// ❌ Don't use Redux for server cache
// Use React Query or RTK Query instead

// ❌ Don't create deep nesting in state
// Normalize instead

// ❌ Don't forget to handle loading/error states

Summary Table

ConceptPurposeExample
StoreSingle state containercreateStore(reducer)
ActionDescribe what happened{ type: 'ADD', payload }
ReducerCompute new state(state, action) => newState
DispatchSend action to storestore.dispatch(action)
SelectorExtract statestate => state.todos
MiddlewareExtend ReduxThunks, logging, error handling

Conclusion

Redux provides predictable state management through:

  • Single store — One source of truth
  • Actions — Describe state changes
  • Reducers — Pure functions computing new state
  • Middleware — Handle side effects

For new projects, use Redux Toolkit which simplifies all of the above. For existing Redux codebases, understanding these fundamentals helps you maintain and refactor effectively.

#redux #state-management #javascript #react #web-development