278 lines
7.3 KiB
Markdown
278 lines
7.3 KiB
Markdown
---
|
|
title: Use Reactive Collections from @ember/reactive/collections
|
|
impact: HIGH
|
|
impactDescription: Enables reactive arrays, maps, and sets
|
|
tags: reactivity, tracked, collections, advanced
|
|
---
|
|
|
|
## Use Reactive Collections from @ember/reactive/collections
|
|
|
|
Use reactive collections from `@ember/reactive/collections` to make arrays, Maps, and Sets reactive in Ember. Standard JavaScript collections don't trigger Ember's reactivity system when mutated—reactive collections solve this.
|
|
|
|
**The Problem:**
|
|
Standard arrays, Maps, and Sets are not reactive in Ember when you mutate them. Changes won't trigger template updates.
|
|
|
|
**The Solution:**
|
|
Use Ember's built-in reactive collections from `@ember/reactive/collections`.
|
|
|
|
### Reactive Arrays
|
|
|
|
**Incorrect (non-reactive array):**
|
|
|
|
```glimmer-js
|
|
// app/components/todo-list.gjs
|
|
import Component from '@glimmer/component';
|
|
import { tracked } from '@glimmer/tracking';
|
|
import { action } from '@ember/object';
|
|
|
|
export default class TodoList extends Component {
|
|
@tracked todos = []; // ❌ Array mutations (push, splice, etc.) won't trigger updates
|
|
|
|
@action
|
|
addTodo(text) {
|
|
// This won't trigger a re-render!
|
|
this.todos.push({ id: Date.now(), text });
|
|
}
|
|
|
|
@action
|
|
removeTodo(id) {
|
|
// This also won't trigger a re-render!
|
|
const index = this.todos.findIndex((t) => t.id === id);
|
|
this.todos.splice(index, 1);
|
|
}
|
|
|
|
<template>
|
|
<ul>
|
|
{{#each this.todos as |todo|}}
|
|
<li>
|
|
{{todo.text}}
|
|
<button {{on "click" (fn this.removeTodo todo.id)}}>Remove</button>
|
|
</li>
|
|
{{/each}}
|
|
</ul>
|
|
<button {{on "click" (fn this.addTodo "New todo")}}>Add</button>
|
|
</template>
|
|
}
|
|
```
|
|
|
|
**Correct (reactive array with @ember/reactive/collections):**
|
|
|
|
```glimmer-js
|
|
// app/components/todo-list.gjs
|
|
import Component from '@glimmer/component';
|
|
import { action } from '@ember/object';
|
|
import { trackedArray } from '@ember/reactive/collections';
|
|
|
|
export default class TodoList extends Component {
|
|
todos = trackedArray([]); // ✅ Mutations are reactive
|
|
|
|
@action
|
|
addTodo(text) {
|
|
// Now this triggers re-render!
|
|
this.todos.push({ id: Date.now(), text });
|
|
}
|
|
|
|
@action
|
|
removeTodo(id) {
|
|
// This also triggers re-render!
|
|
const index = this.todos.findIndex((t) => t.id === id);
|
|
this.todos.splice(index, 1);
|
|
}
|
|
|
|
<template>
|
|
<ul>
|
|
{{#each this.todos as |todo|}}
|
|
<li>
|
|
{{todo.text}}
|
|
<button {{on "click" (fn this.removeTodo todo.id)}}>Remove</button>
|
|
</li>
|
|
{{/each}}
|
|
</ul>
|
|
<button {{on "click" (fn this.addTodo "New todo")}}>Add</button>
|
|
</template>
|
|
}
|
|
```
|
|
|
|
### Reactive Maps
|
|
|
|
Maps are useful for key-value stores with non-string keys:
|
|
|
|
```glimmer-js
|
|
// app/components/user-cache.gjs
|
|
import Component from '@glimmer/component';
|
|
import { action } from '@ember/object';
|
|
import { trackedMap } from '@ember/reactive/collections';
|
|
|
|
export default class UserCache extends Component {
|
|
userCache = trackedMap(); // key: userId, value: userData
|
|
|
|
@action
|
|
cacheUser(userId, userData) {
|
|
this.userCache.set(userId, userData);
|
|
}
|
|
|
|
@action
|
|
clearUser(userId) {
|
|
this.userCache.delete(userId);
|
|
}
|
|
|
|
get cachedUsers() {
|
|
return Array.from(this.userCache.values());
|
|
}
|
|
|
|
<template>
|
|
<ul>
|
|
{{#each this.cachedUsers as |user|}}
|
|
<li>{{user.name}}</li>
|
|
{{/each}}
|
|
</ul>
|
|
<p>Cache size: {{this.userCache.size}}</p>
|
|
</template>
|
|
}
|
|
```
|
|
|
|
### Reactive Sets
|
|
|
|
Sets are useful for unique collections:
|
|
|
|
```glimmer-js
|
|
// app/components/tag-selector.gjs
|
|
import Component from '@glimmer/component';
|
|
import { action } from '@ember/object';
|
|
import { trackedSet } from '@ember/reactive/collections';
|
|
|
|
export default class TagSelector extends Component {
|
|
selectedTags = trackedSet();
|
|
|
|
@action
|
|
toggleTag(tag) {
|
|
if (this.selectedTags.has(tag)) {
|
|
this.selectedTags.delete(tag);
|
|
} else {
|
|
this.selectedTags.add(tag);
|
|
}
|
|
}
|
|
|
|
get selectedCount() {
|
|
return this.selectedTags.size;
|
|
}
|
|
|
|
<template>
|
|
<div>
|
|
{{#each @availableTags as |tag|}}
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={{this.selectedTags.has tag}}
|
|
{{on "change" (fn this.toggleTag tag)}}
|
|
/>
|
|
{{tag}}
|
|
</label>
|
|
{{/each}}
|
|
</div>
|
|
<p>Selected: {{this.selectedCount}} tags</p>
|
|
</template>
|
|
}
|
|
```
|
|
|
|
### When to Use Each Type
|
|
|
|
| Type | Use Case |
|
|
| -------------- | ------------------------------------------------------------------ |
|
|
| `trackedArray` | Ordered lists that need mutation methods (push, pop, splice, etc.) |
|
|
| `trackedMap` | Key-value pairs with non-string keys or when you need `size` |
|
|
| `trackedSet` | Unique values, membership testing |
|
|
|
|
### Common Patterns
|
|
|
|
**Initialize with data:**
|
|
|
|
```javascript
|
|
import { trackedArray, trackedMap, trackedSet } from '@ember/reactive/collections';
|
|
|
|
// Array
|
|
const todos = trackedArray([
|
|
{ id: 1, text: 'First' },
|
|
{ id: 2, text: 'Second' },
|
|
]);
|
|
|
|
// Map
|
|
const userMap = trackedMap([
|
|
[1, { name: 'Alice' }],
|
|
[2, { name: 'Bob' }],
|
|
]);
|
|
|
|
// Set
|
|
const tags = trackedSet(['javascript', 'ember', 'web']);
|
|
```
|
|
|
|
**Convert to plain JavaScript:**
|
|
|
|
```javascript
|
|
// Array
|
|
const plainArray = [...trackedArray];
|
|
const plainArray2 = Array.from(trackedArray);
|
|
|
|
// Map
|
|
const plainObject = Object.fromEntries(trackedMap);
|
|
|
|
// Set
|
|
const plainArray3 = [...trackedSet];
|
|
```
|
|
|
|
**Functional array methods still work:**
|
|
|
|
```javascript
|
|
const todos = trackedArray([...]);
|
|
|
|
// All of these work and are reactive
|
|
const completed = todos.filter(t => t.done);
|
|
const titles = todos.map(t => t.title);
|
|
const allDone = todos.every(t => t.done);
|
|
const firstIncomplete = todos.find(t => !t.done);
|
|
```
|
|
|
|
### Alternative: Immutable Updates
|
|
|
|
If you prefer immutability, you can use regular `@tracked` with reassignment:
|
|
|
|
```javascript
|
|
import { tracked } from '@glimmer/tracking';
|
|
|
|
export default class TodoList extends Component {
|
|
@tracked todos = [];
|
|
|
|
@action
|
|
addTodo(text) {
|
|
// Reassignment is reactive
|
|
this.todos = [...this.todos, { id: Date.now(), text }];
|
|
}
|
|
|
|
@action
|
|
removeTodo(id) {
|
|
// Reassignment is reactive
|
|
this.todos = this.todos.filter((t) => t.id !== id);
|
|
}
|
|
}
|
|
```
|
|
|
|
**When to use each approach:**
|
|
|
|
- Use reactive collections when you need mutable operations (better performance for large lists)
|
|
- Use immutable updates when you want simpler mental model or need history/undo
|
|
|
|
### Best Practices
|
|
|
|
1. **Don't mix approaches** - choose either reactive collections or immutable updates
|
|
2. **Initialize in class field** - no need for constructor
|
|
3. **Use appropriate type** - Map for key-value, Set for unique values, Array for ordered lists
|
|
4. **Export from modules** if shared across components
|
|
|
|
Reactive collections from `@ember/reactive/collections` provide the best of both worlds: mutable operations with full reactivity. They're especially valuable for large lists or frequent updates where immutable updates would be expensive.
|
|
|
|
**References:**
|
|
|
|
- [Ember Reactivity System](https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/)
|
|
- [JavaScript Built-in Objects](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects)
|
|
- [Reactive Collections RFC](https://github.com/emberjs/rfcs/blob/master/text/0869-reactive-collections.md)
|