What is Map()
?
Map()
in JavaScript is a collection type introduced in ECMAScript 2015 (ES6) that allows you to store key-value pairs. Unlike objects, which only support string or symbol keys, a Map()
can use keys of any type, including objects, functions, and any primitive type.
Why are we using Map()
?
In a JavaScript ecosystem, you will sometimes work with Map()
to solve some O(n) or O(n^2) problems, making your client-side experience more seamless.
We can also use Map()
inside a reducer function for useReducer
in ReactJS, But are you using Map
correctly inside the reducer and following ReactJS paradigms and rules?
Let’s take a look at when it is a good fit to use Map()
inside a reducer. Imagine you have a feature for an X and Y table like this, and the user interaction should be as follows:
every column and row is clickable and the value is either true
or false
If “Select All” is clicked, then all the values below its column will be automatically checked.
Here, we can define the map key as follows col-${y}-username-${x}
, and the result will look like this:
So, if you are clicking at an X,Y coordinate, you just provide the unique identifier and modify the value using the Map.get()
and Map.set()
methods.
Implementation using a reducer.
Now, let’s try to implement the previous idea into ReactJS code that uses a reducer. The code snippet will look like this:
// reducer.ts
import { ACTIONTYPE, EditValueENUM, InitialState } from "../types";
const initialState: InitialState = {
select: new Map(),
};
function reducer(state: InitialState, action: ACTIONTYPE): InitialState {
switch (action.type) {
case EditValueENUM.ON_CHANGE: {
const tempMap = state.select;
tempMap.set(action.identifier, true);
return {
select: tempMap,
};
}
default:
throw new Error();
}
}
export { initialState, reducer };
// provider.tsx
const select = new Map();
for (let a = 0; a < 10; a++) {
for (let b = 0; b < 10; b++) {
select.set(`col-${b}-username-${a}`, false);
}
}
const EditProvider = ({ children }: AppProps) => {
const [state, dispatch] = useReducer(reducer, {
select: select,
});
return (
<EditDispatch.Provider value={dispatch}>
<EditValue.Provider value={state}>{children}</EditValue.Provider>
</EditDispatch.Provider>
);
};
const useEdit = () => useContext(EditValue);
const useEditDispatch = () => useContext(EditDispatch);
// consumer.tsx
const { select } = useEdit();
const dispatch = useEditDispatch();
<Checkbox
onChange={() => {
dispatch({
type: EditValueENUM.ON_CHANGE,
identifier: `col-${yCol}-username-${yUser}`,
});
}}
checked={select.get(`col-${yCol}-username-${yUser}`)}
/>;
However, we have a problem here: this code will not work in development mode if we are using strict mode.
Let’s try adding a console log in the reducer, and let’s try clicking any of the checkboxes in the UI. The UI will feel like nothing happened, but if we check the browser console, it will show 2 re-renders because of React Strict Mode behavior. The first render will return true
, which is expected as we want, but the second render will return false
, reverting to the original value.
// reducer.ts
case EditValueENUM.ON_CHANGE: {
const tempMap = state.select;
tempMap.set(action.identifier, true);
// add console.log here
console.log(action.identifier, tempMap.get(action.identifier));
return {
select: tempMap,
};
}
This happened because we are violating one of the React rules, which is state immutability.
Why is this happening, and how can it be fixed?
Now, let’s dive deeper into a more technical explanation of why this is happening. As mentioned in the React documentation:
State can hold any kind of JavaScript value, including objects. But you shouldn’t change objects that you hold in the React state directly. Instead, when you want to update an object, you need to create a new one (or make a copy of an existing one), and then set the state to use that copy.
🗣️: “But we have already made a copy of the state, set it to the new variables, changed the new variables, and given the reducer the value from the new variables.”
// reducer.ts
const tempMap = state.select;
tempMap.set(action.identifier, true);
return {
select: tempMap,
};
Yes, that would be one of the first responses that come to mind (including mine) if we are not focused enough.
Immutability in React state management is a core principle. It means once you create an object, you cannot change its state or content. Instead, you create a new object with the desired changes. React relies on this principle to detect state changes and decide whether a component needs to re-render. When you mutate state directly (like setting a value in a Map
), React may not detect this change because the reference to the Map
object itself has not changed, even though its content has.
Why Our Code Behaves Unexpectedly?
In strict mode, React duplicates the render phase to catch side effects. When you mutate the state directly (as with the Map
in your code), the first render reflects the change, but React may revert to the initial state in the second simulated render, as it tries to detect side effects. Since the reference to the Map
doesn’t change, React’s reconciliation algorithm might not properly account for the changes made within it across these simulated renders.
JavaScript’s Map and React
Direct Mutation: If you directly mutate a Map
(with set
, delete
, etc.), you’re changing the internal state of the Map
without changing its reference. React’s shallow comparison fails to detect these changes because the object reference remains the same. This can lead to unpredictable component behavior and rendering issues, especially in strict mode where React is more sensitive to such side effects.
Correct Approach: The correct approach is to treat every state as immutable. When you need to update a Map
, you should create a new Map
instance based on the current state and then apply your changes. This new Map
has a different reference, allowing React to detect the change and update the component accordingly.
// reducer.ts
case EditValueENUM.ON_CHANGE: {
// Create a new Map instance based on the current state's Map
const currMap = new Map(state.select);
const currValueOfIdentifier = currMap.get(action.identifier);
// Update the new Map
currMap.set(action.identifier, !currValueOfIdentifier);
// Return the updated state with the new Map
return {
select: currMap
};
}
How do references in JavaScript objects work?
In the context of JavaScript and most programming languages, when we talk about references
, we are conceptually referring to memory addresses, although the specifics can vary between languages and their implementations.
A reference points to the location in memory where the data for an object (such as an object
, array
, or Map
) is stored. When you assign an object to a variable, the variable holds a reference to the memory address of that object, not the actual data of the object itself. This is why when you assign one object to another variable, both variables point to the same memory address—they are both references to the same object.
Assignment: When you assign an object to a variable, you’re giving that variable a reference (think of it as the memory address) to where the object’s data is stored.
Mutation: If you modify the object through any of the variables referencing it, the change is reflected across all references because they all point to the same data in memory.
Comparison: Comparing two references with ===
checks if they point to the same memory location. It does not compare the content of the objects.
let obj1 = { value: 10 };
// obj2 now references the same memory location as obj1.
let obj2 = obj1;
// Mutating the object through obj2.
obj2.value = 20;
// Outputs: 20, because obj1 and obj2 refer to the same object.
console.log(obj1.value);
// A new object with its own memory location.
let obj3 = { value: 20 };
// Outputs: true, because they reference the same memory location.
console.log(obj1 === obj2);
// Outputs: false, even though the contents are the same, they are in different memory locations.
console.log(obj1 === obj3);
Conclusion
While the concept of references is tied to memory addresses, in high-level languages like JavaScript, you don’t directly work with memory addresses. The JavaScript engine manages memory allocation, garbage collection, and references for you. The term reference
is used to convey the idea that variables point to the same underlying data in memory, rather than holding their own separate copies of the data.
This is why, when we are using either Set
, Array
, or Map
, and working with them inside the React ecosystem, we need to be careful about our approach to changing the data.