Principles
- Everything that changes in your application, including the data and the UI state, is contained in a single object, we call the state or the state tree.
- The state tree is read only. Anytime you want to change the state, you need to dispatch an action. An action is a plain JavaScript object describing the change. Just like the state is the minimal representation of the data in your app, the action is the minimal representation of the change to that data.
- To describe state mutations, you have to write a function that takes the previous state of the app, the action being dispatched, and returns the next state of the app. This function has to be pure. This function is called the “Reducer.”
Writing a Reducer Function
The convention we use in Redux is that if the reducer receives undefined as the straight argument, it must return what it considers to be the initial straight of the application.
Example
// Initial state
const initialState = {
todos: [],
};
// Reducer function
const todoReducer = (state = initialState, action) => {
switch (action.type) {
case "ADD_TODO":
return {
...state,
todos: [...state.todos, action.payload], // Add the new to-do to the list
};
case "REMOVE_TODO":
return {
...state,
todos: state.todos.filter((todo, index) => index !== action.payload), // Remove to-do by index
};
case "TOGGLE_TODO":
return {
...state,
todos: state.todos.map((todo, index) =>
index === action.payload
? { ...todo, completed: !todo.completed }
: todo
), // Toggle the completed status of the to-do
};
default:
return state; // Return the current state if action type doesn't match
}
};
export default todoReducer;Redux Store
This store binds together the three principles of Redux. It holds the current application’s state object, lets you dispatch actions, and when you create it, you need to specify the reducer that tells how state is updated with actions.
getState(): retrieves the current state of the Redux storedispatch(): dispatches actions to change the state of your applicationsubscribe(): registers a callback that the Redux chore will call any time an action has been dispatched
Example
// Redux-like store implementation
function createStore(reducer, initialState) {
let state = initialState; // Internal state
let listeners = []; // List of subscriber functions
// Get the current state
function getState() {
return state;
}
// Dispatch an action to update the state
function dispatch(action) {
state = reducer(state, action); // Update state based on reducer logic
listeners.forEach((listener) => listener()); // Notify subscribers
}
// Subscribe to state changes
function subscribe(listener) {
listeners.push(listener); // Add listener to the list
return function unsubscribe() {
listeners = listeners.filter((l) => l !== listener); // Remove listener
};
}
// Initialize the state with a dummy action
dispatch({ type: "@@INIT" });
// Return public methods
return { getState, dispatch, subscribe };
}
// Example usage:
// Reducer function
function counterReducer(state = 0, action) {
switch (action.type) {
case "INCREMENT":
return state + 1;
case "DECREMENT":
return state - 1;
default:
return state;
}
}
// Create the store
const store = createStore(counterReducer);
// Subscribe to state changes
const unsubscribe = store.subscribe(() => {
console.log("State changed:", store.getState());
});
// Dispatch some actions
store.dispatch({ type: "INCREMENT" }); // State: 1
store.dispatch({ type: "INCREMENT" }); // State: 2
store.dispatch({ type: "DECREMENT" }); // State: 1
// Unsubscribe from state changes
unsubscribe();
// Dispatch more actions (won't trigger subscription callback)
store.dispatch({ type: "INCREMENT" }); // State: 2Avoiding Array Mutations with concat(), slice(), and ...spread
Avoiding array mutability is essential for the following reasons:
- Immutability ensures state updates are predictable.
- Redux DevTools can properly track state changes.
- Prevents bugs caused by unintended side effects.
This approach adheres to Redux best practices by treating state as immutable.
Redux Reducer Avoiding Array Mutations
Let’s assume you are managing a list of items in the Redux store. Here’s the intial state:
const initialState = {
items: [1, 2, 3],
};Here’s a reducer handling three actions: adding, removing, and updating items.
function itemsReducer(state = initialState, action) {
switch (action.type) {
case "ADD_ITEM":
// Use concat() to avoid mutating the array
return {
...state,
items: state.items.concat(action.payload),
};
case "REMOVE_ITEM":
// Use slice() and spread operator to remove an item by index
return {
...state,
items: [
...state.items.slice(0, action.payload), // Before the index
...state.items.slice(action.payload + 1), // After the index
],
};
case "UPDATE_ITEM":
// Use map() to create a new array with the updated item
return {
...state,
items: state.items.map((item, index) =>
index === action.payload.index ? action.payload.newValue : item
),
};
default:
return state;
}
}Here are some actions that the reducer can handle:
const addItemAction = {
type: "ADD_ITEM",
payload: 4, // Add the number 4 to the items array
};
const removeItemAction = {
type: "REMOVE_ITEM",
payload: 1, // Remove the item at index 1 (value: 2)
};
const updateItemAction = {
type: "UPDATE_ITEM",
payload: {
index: 2, // Update the item at index 2
newValue: 42, // New value is 42
},
};State transitions:
- Initial State:
{ items: [1, 2, 3] } - Add Item (4):
{ items: [1, 2, 3, 4] } - Remove Item (index 1):
{ items: [1, 3, 4] } - Update Item (index 2 → 42):
{ items: [1, 3, 42] }
Avoiding Object Mutations with Object.assign() and ...spread
Why Avoid Object Mutations in Redux?
- Preserves State Immutability: Enables time travel debugging in Redux DevTools.
- Prevents Side Effects: Avoids unexpected changes elsewhere in the app.
- Ensures Predictable State Changes: Makes reducers and actions easier to test.
Using either Object.assign() or the spread operator ensures you follow Redux’s
immutability best practices. The spread operator is generally preferred for its
readability and brevity.
Example
Initial state:
const initialState = {
user: {
id: 1,
name: "Alice",
age: 25,
},
};Here’s a reducer handling actions to update the user object:
function userReducer(state = initialState, action) {
switch (action.type) {
case "UPDATE_USER_WITH_ASSIGN":
// Use Object.assign() to create a new object without mutating
return {
...state,
user: Object.assign({}, state.user, action.payload),
};
case "UPDATE_USER_WITH_SPREAD":
// Use the spread operator to create a new object without mutating
return {
...state,
user: {
...state.user,
...action.payload,
},
};
default:
return state;
}
}Actions example:
-
Update Name:
const updateNameAction = { type: "UPDATE_USER_WITH_ASSIGN", payload: { name: "Bob" }, // Update the user's name to 'Bob' }; -
Update Age:
const updateAgeAction = { type: "UPDATE_USER_WITH_SPREAD", payload: { age: 30 }, // Update the user's age to 30 };
State transitions:
-
Initial State:
{ user: { id: 1, name: 'Alice', age: 25 } } -
After
updateNameAction:{ user: { id: 1, name: 'Bob', age: 25 } } -
After
updateAgeAction:{ user: { id: 1, name: 'Bob', age: 30 } }
Key Differences Between Object.assign() and Spread:
-
Object.assign():- Syntax:
Object.assign(target, ...sources) - Creates a new object by copying properties from source objects to the target.
- Used in ES5 and older versions.
Example:
const newUser = Object.assign({}, state.user, { name: "Bob" }); - Syntax:
-
Spread Operator (
...):- Syntax:
{ ...source, ...updates } - Creates a new object with properties from the source and overrides with updates.
- More concise and modern syntax (ES6).
Example:
const newUser = { ...state.user, name: "Bob" }; - Syntax: