React: Setting state to an es6 Map
Asked Answered
V

4

36

This is more of a general, best-practices question.

I've been playing around here and there with JavaScript Maps and have been trying to find more info on whether or not it's considered an anti-pattern/code smell to set state properties to a Map. The link below is an issue thread in the Redux repo with some comments such as:

"You can use Maps and Sets as state values, but it's not recommended due to serializability concerns."

However this thread is about Redux. What about vanilla React? Anyone have any strong opinions or insight? Sorry if this question is in the wrong place.

https://github.com/reduxjs/redux/issues/1499

Victorie answered 4/12, 2018 at 4:35 Comment(3)
This is an interesting question - but is probably more on topic at softwareengineering.stackexchange.com, also - could you post an example of what you're meaning?Historiographer
The creator of Redux said that React state is the same as Redux store, it's recommended it be serializable, though it's not a requirement.Sutphin
@Historiographer I don't really have an example, it was just something I was wondering about. I could give a generic, contrived example but it would just be a setState call like so: this.setState({ map: new Map() })Victorie
C
29

React state should be immutable because React uses shallow compare to check for equality. When comparing scalar values (numbers, strings) it compares their values. When comparing objects, it does not compare their properties - only their references are compared (i.e. "do they point to same object ?").

ES6 Maps are not immutable and are optimized for mutability, that's why it's not recommended to use these in React as it is. React will not know whether map is updated or not.

var map1 = new Map();
var map2 = map1.set('b', 2); // mutate map
map1 === map2; // true because reference remains unchanged after mutation

You can use Maps if you want but you need to use some immutability helper e.g. Immutable.js. Following example is using immutable map

const { Map } = require('immutable');
const map1 = Map({ a: 1, b: 2, c: 3 });
const map2 = map1.set('b', 2); // Set to same value
map1 === map2; // true
const map3 = map1.set('b', 4); // Set to different value
map1 === map3; // false

References:

https://github.com/reduxjs/redux/issues/1499#issuecomment-194002599

https://mcmap.net/q/129500/-how-does-shallow-compare-work-in-react

Clydesdale answered 4/12, 2018 at 5:38 Comment(0)
C
37

If you don't want to use an immutable library you can create a new Map on change (this is a shallow copy of the map, like spreading an object):

const [someMap, setSomeMap] = useState(new Map())

And when you need to update it:

setSomeMap(new Map(someMap).set('someKey', 'a new value'))

The same concept applies to Redux:

case 'SomeAction':
  return {
    ...state,
    yourMap: new Map(state.yourMap).set('someKey', 'a new value')
  }

With regards to serializability it's not a concern for local state. It's good practice to have a Redux store that is serializable though.

Can I put functions, promises, or other non-serializable items in my store state?

It is highly recommended that you only put plain serializable objects, arrays, and primitives into your store. It's technically possible to insert non-serializable items into the store, but doing so can break the ability to persist and rehydrate the contents of a store, as well as interfere with time-travel debugging.

If you are okay with things like persistence and time-travel debugging potentially not working as intended, then you are totally welcome to put non-serializable items into your Redux store. Ultimately, it's your application, and how you implement it is up to you. As with many other things about Redux, just be sure you understand what tradeoffs are involved.

You can see that JSON.stringify unfortunately doesn't work on maps:

console.log(JSON.stringify(
  new Map([['key1', 'value1'], ['key2', 'value2']])
))

If you can get in between the serialization process you can use Array.from:

console.log(JSON.stringify(
  Array.from(new Map([['key1', 'value1'], ['key2', 'value2']]))
))
Cowitch answered 18/6, 2020 at 17:4 Comment(9)
should be setSomeMap(new Map(someMap.set('someKey', 'a new value')));Giustino
Is this recommended though? Is this okay? Is it less efficient than just using an object or another data structure?Francesco
To add to that, setSomeMap(new Map(someMap.set('someKey', 'a new value'))); can be written as setSomeMap(someMap.set('someKey', 'a new value'));, you don't need to create a new mapTrussing
@AlexMandelias I'm surprised that works as I though set returns the same Map instance and so an equality check would return that they are equal and not trigger a re-render?Cowitch
@Dominic You're absolutely right. Turns out, in my case I didn't even need to re-render (maybe useRef would have been more appropriate), so I completely missed your point when testing in my project. Thanks for bringing my attention to it!Trussing
My understanding is that you should copy the Map before modifying. Otherwise it violates React's rules. react.dev/reference/rules/…Uptodate
new Map is copying the map @UptodateCowitch
But you are modifying the Map then copying it. I think you need to copy first. Otherwise you are mutating state.Uptodate
I see, should affect React but could affect 3rd party tooling e.g. time travel tools for Redux, new Map(someMap).set('someKey', 'a new value') should solve that. UpdatedCowitch
C
29

React state should be immutable because React uses shallow compare to check for equality. When comparing scalar values (numbers, strings) it compares their values. When comparing objects, it does not compare their properties - only their references are compared (i.e. "do they point to same object ?").

ES6 Maps are not immutable and are optimized for mutability, that's why it's not recommended to use these in React as it is. React will not know whether map is updated or not.

var map1 = new Map();
var map2 = map1.set('b', 2); // mutate map
map1 === map2; // true because reference remains unchanged after mutation

You can use Maps if you want but you need to use some immutability helper e.g. Immutable.js. Following example is using immutable map

const { Map } = require('immutable');
const map1 = Map({ a: 1, b: 2, c: 3 });
const map2 = map1.set('b', 2); // Set to same value
map1 === map2; // true
const map3 = map1.set('b', 4); // Set to different value
map1 === map3; // false

References:

https://github.com/reduxjs/redux/issues/1499#issuecomment-194002599

https://mcmap.net/q/129500/-how-does-shallow-compare-work-in-react

Clydesdale answered 4/12, 2018 at 5:38 Comment(0)
H
3

For the rare use cases where a Map/Set/WeakMap/WeakSet are the right tool, you can absolutely use them as state, albeit a bit "weird". (Caution: Here be dragons)

The problem, as other posters have mentioned, is that React compares state and props by referential equality - Object.is(oldState, newState) -, at which point React is like "oh, this is the same Map, I don't need to do anything."

So per the other answers, you would either have to use a dependency (immutable), or copy the whole Map to a new Map, which can be very slow, depending on the size of your data.

As a valid workaround, you can set an object as state that contains a Map/Set, which still allows you to cause a rerender when the map was updated:

// Lets assume we receive items which can be reordered or filtered,
// but remain the same object reference unless they change -
// and we do not know if the item objects have a primary key (.id).
// 
// We still want to persist if an item is selected or not.
//
function ListOfThousandObjects({ items }) {
  const [selected, setSelected] = useState({ items: new Map() });

  const toggleSelected = useCallback(item => {
    setSelected((selected) => {
      selected.items.set(item, !selected.items.get(item));
      return { items: selected.items };
      // ^ We return a new item, causing a rerender -
      //   without having to copy all the items in the Map.
    });
  }, []);

  useEffect(() => {
    saveSelectionToSessionStorage(selected.items);
  }, [selected]);
  // ^ This feels weird but works, as the object
  //   reference changes, even if the map is the same

  return <ul>
    {items.map(item => (
      <ObjectListRow
        key={generateStableUniqueId(item)}
        item={item}
        onToggle={() => toggleSelected(item)
      />
    ))}
  </ul>
}
Hippel answered 11/10, 2023 at 19:35 Comment(1)
This is the type of workaround that deserves a double like. Thank you. Incredibly fast compared to copying the map on every rerender.Orjonikidze
P
0

While I love immutable.js, I don't recommend using it, because it has many circular dependencies between its types, which means that any tree shaking is ineffective, leading to a substantial chunk in your bundle (131 KB parsed or 18 KB zipped for v5), even if you only use one of its types (e.g. immutable Map).

Instead, I recommend using the built-in Map with object indirection (as already suggested by Leon Adler), since assignment creates a new container object, causing React to re-render.

JavaScript:

const [someMap, setSomeMap] = useState({ map: new Map() });

function getSomeMapValue(key) {
    return someMap.map.get(key);
}

function updateSomeMap(key, value) {
    setSomeMap(({ map }) => ({ map: map.set(key, value) }));
}

TypeScript:

const [someMap, setSomeMap] = useState<{ map: Map<string, string> }>({ map: new Map() });

function getSomeMapValue(key: string): string | undefined {
    return someMap.map.get(key);
}

function updateSomeMap(key: string, value: string): void {
    setSomeMap(({ map }) => ({ map: map.set(key, value) }));
}

This way, you can do O(1) updates, have a map as part of React state that preserves insertion order, and have a small(er) bundle.

Proto answered 19/3 at 18:46 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.