Skip to main content

Reactive Collections: Automatic Reactivity for Arrays, Sets, and Maps

Β· 6 min read
Fabio Fognani
random nerd

I'm excited to announce a powerful enhancement to use-less-react that dramatically simplifies state management when working with collections. Starting with version 0.10.1, makeReactiveProperties now supports automatic reactivity for Arrays, Sets, and Maps.

This means you can mutate collections using their native methodsβ€”push(), add(), set()β€”and React components will automatically re-render. No manual notify() calls, no cloning, no immutability gymnastics. Just natural, intuitive JavaScript.

The Problem: Managing Collection State in React​

Working with collections in React has traditionally been verbose and error-prone. Consider a simple todo list:

class TodoStore extends PubSub {
todos: Todo[] = [];

constructor() {
super();
this.makeReactiveProperties('todos');
}

addTodo(text: string) {
// Before v0.10.1: you had to clone the array
this.todos = [...this.todos, { id: Date.now(), text, completed: false }];
// Or manually notify after mutation
// this.todos.push({ id: Date.now(), text, completed: false });
// this.notify('todos');
}
}

This approach has several drawbacks:

  • Verbose: Every mutation requires either array cloning or manual notifications
  • Error-Prone: It's easy to forget the notify() call or spread operator
  • Unnatural: Developers must abandon native array methods they know and love
  • Performance: Unnecessary allocations when cloning large arrays

The Solution: Reactive Proxies​

With v0.10.1, collections wrapped by makeReactiveProperties become reactive proxies that automatically detect mutations and trigger React re-renders.

Reactive Arrays​

Arrays now automatically notify on all mutating operations:

class TodoStore extends PubSub {
todos: Todo[] = [];

constructor() {
super();
this.makeReactiveProperties('todos');
}

addTodo(text: string) {
// ✨ Just use native push() - automatic notification!
this.todos.push({
id: Date.now(),
text,
completed: false
});
}

toggleTodo(id: number) {
const index = this.todos.findIndex(t => t.id === id);
if (index >= 0) {
// ✨ Index assignment also notifies automatically
this.todos[index] = {
...this.todos[index],
completed: !this.todos[index].completed
};
}
}

removeTodo(id: number) {
const index = this.todos.findIndex(t => t.id === id);
if (index >= 0) {
// ✨ splice() automatically notifies
this.todos.splice(index, 1);
}
}
}

Supported mutating methods:

  • Index assignments: arr[0] = value
  • push(), pop(), shift(), unshift()
  • splice(), sort(), reverse()
  • fill(), copyWithin()

Non-mutating methods like map(), filter(), find(), slice() don't trigger notifications (as expected).

Reactive Sets​

Sets become reactive proxies that notify on mutations:

class TagManager extends PubSub {
tags: Set<string> = new Set();

constructor() {
super();
this.makeReactiveProperties('tags');
}

addTag(tag: string) {
// ✨ Automatically notifies
this.tags.add(tag.toLowerCase());
}

removeTag(tag: string) {
// ✨ Automatically notifies
this.tags.delete(tag.toLowerCase());
}

clearTags() {
// ✨ Automatically notifies
this.tags.clear();
}
}

Supported mutating methods:

  • add(value)
  • delete(value)
  • clear()

Reactive Maps​

Maps also get automatic reactivity:

class ConfigStore extends PubSub {
settings: Map<string, string> = new Map();

constructor() {
super();
this.makeReactiveProperties('settings');
}

setSetting(key: string, value: string) {
// ✨ Automatically notifies
this.settings.set(key, value);
}

removeSetting(key: string) {
// ✨ Automatically notifies
this.settings.delete(key);
}
}

Supported mutating methods:

  • set(key, value)
  • delete(key)
  • clear()

Mix and Match: Collections + Primitives​

You can combine reactive collections with primitive reactive properties seamlessly:

class AppState extends PubSub {
items: string[] = [];
tags: Set<string> = new Set();
metadata: Map<string, unknown> = new Map();
count = 0;

constructor() {
super();
// All properties become reactive
this.makeReactiveProperties('items', 'tags', 'metadata', 'count');
}

increment() {
this.count++; // Notifies
}

addItem(item: string) {
this.items.push(item); // Notifies
}

addTag(tag: string) {
this.tags.add(tag); // Notifies
}

setMeta(key: string, value: unknown) {
this.metadata.set(key, value); // Notifies
}
}

Batch Notifications for Bulk Operations​

As always, when performing multiple mutations, use batchNotifications() to trigger only a single React re-render:

class TodoStore extends PubSub {
todos: Todo[] = [];
tags: Set<string> = new Set();

constructor() {
super();
this.makeReactiveProperties('todos', 'tags');
}

async bulkAdd(items: string[]) {
await this.batchNotifications(async () => {
items.forEach(text => {
// Multiple mutations...
this.todos.push({ id: Date.now(), text, completed: false });

// Extract hashtags
const tags = text.match(/#\w+/g) || [];
tags.forEach(tag => this.tags.add(tag));
});
// Only ONE notification sent to React!
});
}
}

See full specs of batchNotifications here

Important: WeakMap and WeakSet are not supported​

Due to their non-enumerable nature, WeakMap and WeakSet cannot be made reactive. If you attempt to make them reactive, an error will be thrown:

class Cache extends PubSub {
cache: WeakMap<object, string> = new WeakMap();

constructor() {
super();
// ❌ Throws error
this.makeReactiveProperties('cache');
}
}

Solution: Use Map or Set instead for reactive collections.

Important: map, forEach and all non-mutating methods won't automatically call notify​

All non-mutating methods like map, forEach, reduce etc. won't automatically call notify. Why? Because they aren't inherently mutators. We cannot make assumptions on how they're used.

So if you need to use one of these methods, please call notify manually, i.e.

class SomeClass extends PubSub {
someArray: Array<{ isChecked: boolean }> = [];

someMethod() {
this.someArray.forEach(item => {
if (someCondition) {
item.isChecked = true;
this.notify("someArray"); // call notify manually
}
})
}
}

Using Reactive Collections in React Components​

Consuming reactive collections in components with useReactiveInstance is easy, but you have to pay attention to a subtle detail. If the reactive collection is a Set, for instance, you cannot simply write:

function TodoList() {
const { state, instance } = useReactiveInstance(
() => new MyClass(),
(instance) => ({
someSet: instance.someSet,
}),
['someSet']
);
}

Why? Because the reference of the Set is stable, so no changes will be detected on it. You will have to convert it to an immutable object or array, in order for useReactiveInstance to actually detect changes.

For instance, if someSet: Set<string>Β you could transform it in an array of strings:

function TodoList() {
const { state, instance } = useReactiveInstance(
() => new MyClass(),
(instance) => ({
someSet: Array.from(instance.someSet),
}),
['someSet']
);
}

Now, once notified, useReactiveInstance will detect that the new snapshot is different from the previous, and the reactive value will be updated correctly!

TLDR; you cannot return stable instance references in the getSnapshot function of useReactiveInstance, so please transform reactive collections accordingly.

The Benefits​

βœ… Zero Boilerplate - No manual notify() calls for collections βœ… Intuitive API - Use native array/set/map methods as usual βœ… Type-Safe - Full TypeScript support and inference βœ… Performance - Only mutating operations trigger notifications βœ… Batching Support - Multiple mutations can be batched efficiently βœ… Developer Experience - Write code that feels natural and idiomatic

Migration from Previous Versions​

If you're already using makeReactiveProperties with collections, your code will continue to work. The enhancement is backward compatible:

Before v0.10.1:

addTodo(text: string) {
this.todos = [...this.todos, { id: Date.now(), text, completed: false }];
// Still works, but now you can use push() instead!
}

After v0.10.1:

addTodo(text: string) {
// Simpler and more intuitive
this.todos.push({ id: Date.now(), text, completed: false });
}

Final Note​

Reactive collections are available starting from v0.10.1.

For full API details and advanced usage, check out the makeReactiveProperties documentation.


With reactive collections, use-less-react takes another step toward making state management in React applications simple, predictable, and enjoyable!