Add ember-best-practices skill
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
---
|
||||
title: Avoid Legacy Lifecycle Hooks (did-insert, will-destroy, did-update)
|
||||
impact: HIGH
|
||||
impactDescription: Prevents memory leaks and enforces modern patterns
|
||||
tags: components, lifecycle, anti-pattern, modifiers, derived-data
|
||||
---
|
||||
|
||||
## Avoid Legacy Lifecycle Hooks (did-insert, will-destroy, did-update)
|
||||
|
||||
**Never use `{{did-insert}}`, `{{will-destroy}}`, or `{{did-update}}` in new code.** These legacy helpers create coupling between templates and component lifecycle, making code harder to test and maintain. Modern Ember provides better alternatives through derived data and custom modifiers.
|
||||
|
||||
### Why These Are Problematic
|
||||
|
||||
1. **Memory Leaks**: Easy to forget cleanup, especially with `did-insert`
|
||||
2. **Tight Coupling**: Mixes template concerns with JavaScript logic
|
||||
3. **Poor Testability**: Lifecycle hooks are harder to unit test
|
||||
4. **Not Composable**: Can't be easily shared across components
|
||||
5. **Deprecated Pattern**: Not recommended in modern Ember
|
||||
|
||||
### Alternative 1: Use Derived Data
|
||||
|
||||
For computed values or reactive transformations, use getters and `@cached`:
|
||||
|
||||
**❌ Incorrect (did-update):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-greeting.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
class UserGreeting extends Component {
|
||||
@tracked displayName = '';
|
||||
|
||||
@action
|
||||
updateDisplayName() {
|
||||
// Runs on every render - inefficient and error-prone
|
||||
this.displayName = `${this.args.user.firstName} ${this.args.user.lastName}`;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div {{did-update this.updateDisplayName @user}}>
|
||||
Hello,
|
||||
{{this.displayName}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**✅ Correct (derived data with getter):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-greeting.gjs
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
class UserGreeting extends Component {
|
||||
// Automatically reactive - updates when args change
|
||||
get displayName() {
|
||||
return `${this.args.user.firstName} ${this.args.user.lastName}`;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div>
|
||||
Hello,
|
||||
{{this.displayName}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**✅ Even better (use @cached for expensive computations):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-stats.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { cached } from '@glimmer/tracking';
|
||||
|
||||
class UserStats extends Component {
|
||||
@cached
|
||||
get sortedPosts() {
|
||||
// Expensive computation only runs when @posts changes
|
||||
return [...this.args.posts].sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
@cached
|
||||
get statistics() {
|
||||
return {
|
||||
total: this.args.posts.length,
|
||||
published: this.args.posts.filter((p) => p.published).length,
|
||||
drafts: this.args.posts.filter((p) => !p.published).length,
|
||||
};
|
||||
}
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<p>Total: {{this.statistics.total}}</p>
|
||||
<p>Published: {{this.statistics.published}}</p>
|
||||
<p>Drafts: {{this.statistics.drafts}}</p>
|
||||
|
||||
<ul>
|
||||
{{#each this.sortedPosts as |post|}}
|
||||
<li>{{post.title}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
### Alternative 2: Use Custom Modifiers
|
||||
|
||||
For DOM side effects, element setup, or cleanup, use custom modifiers:
|
||||
|
||||
**❌ Incorrect (did-insert + will-destroy):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/chart.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
class Chart extends Component {
|
||||
chartInstance = null;
|
||||
|
||||
@action
|
||||
setupChart(element) {
|
||||
this.chartInstance = new Chart(element, this.args.config);
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy();
|
||||
// Easy to forget cleanup!
|
||||
this.chartInstance?.destroy();
|
||||
}
|
||||
|
||||
<template>
|
||||
<canvas {{did-insert this.setupChart}}></canvas>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**✅ Correct (custom modifier with automatic cleanup):**
|
||||
|
||||
```javascript
|
||||
// app/modifiers/chart.js
|
||||
import { modifier } from 'ember-modifier';
|
||||
import { registerDestructor } from '@ember/destroyable';
|
||||
|
||||
export default modifier((element, [config]) => {
|
||||
// Setup
|
||||
const chartInstance = new Chart(element, config);
|
||||
|
||||
// Cleanup happens automatically
|
||||
registerDestructor(element, () => {
|
||||
chartInstance.destroy();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/components/chart.gjs
|
||||
import chart from '../modifiers/chart';
|
||||
|
||||
<template>
|
||||
<canvas {{chart @config}}></canvas>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Alternative 3: Use Resources for Lifecycle Management
|
||||
|
||||
For complex state management with automatic cleanup, use `ember-resources`:
|
||||
|
||||
**❌ Incorrect (did-insert for data fetching):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-profile.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
class UserProfile extends Component {
|
||||
@tracked userData = null;
|
||||
@tracked loading = true;
|
||||
controller = new AbortController();
|
||||
|
||||
@action
|
||||
async loadUser() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const response = await fetch(`/api/users/${this.args.userId}`, {
|
||||
signal: this.controller.signal,
|
||||
});
|
||||
this.userData = await response.json();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy();
|
||||
this.controller.abort(); // Easy to forget!
|
||||
}
|
||||
|
||||
<template>
|
||||
<div {{did-insert this.loadUser}}>
|
||||
{{#if this.loading}}
|
||||
Loading...
|
||||
{{else}}
|
||||
{{this.userData.name}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**✅ Correct (Resource with automatic cleanup):**
|
||||
|
||||
```javascript
|
||||
// app/resources/user-data.js
|
||||
import { Resource } from 'ember-resources';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export default class UserDataResource extends Resource {
|
||||
@tracked data = null;
|
||||
@tracked loading = true;
|
||||
controller = new AbortController();
|
||||
|
||||
modify(positional, named) {
|
||||
const [userId] = positional;
|
||||
this.loadUser(userId);
|
||||
}
|
||||
|
||||
async loadUser(userId) {
|
||||
this.loading = true;
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}`, {
|
||||
signal: this.controller.signal,
|
||||
});
|
||||
this.data = await response.json();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
// Cleanup happens automatically
|
||||
this.controller.abort();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-profile.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import UserDataResource from '../resources/user-data';
|
||||
|
||||
class UserProfile extends Component {
|
||||
userData = UserDataResource.from(this, () => [this.args.userId]);
|
||||
|
||||
<template>
|
||||
{{#if this.userData.loading}}
|
||||
Loading...
|
||||
{{else}}
|
||||
{{this.userData.data.name}}
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
### When to Use Each Alternative
|
||||
|
||||
| Use Case | Solution | Why |
|
||||
| ---------------- | ----------------------------------- | ----------------------------------------- |
|
||||
| Computed values | Getters + `@cached` | Reactive, efficient, no lifecycle needed |
|
||||
| DOM manipulation | Custom modifiers | Encapsulated, reusable, automatic cleanup |
|
||||
| Data fetching | getPromiseState from warp-drive | Declarative, automatic cleanup |
|
||||
| Event listeners | `{{on}}` modifier | Built-in, automatic cleanup |
|
||||
| Focus management | Custom modifier or ember-focus-trap | Proper lifecycle, accessibility |
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
If you have existing code using these hooks:
|
||||
|
||||
1. **Identify the purpose**: What is the hook doing?
|
||||
2. **Choose the right alternative**:
|
||||
- Deriving data? → Use getters/`@cached`
|
||||
- DOM setup/teardown? → Use a custom modifier
|
||||
- Async data loading? → Use getPromiseState from warp-drive
|
||||
3. **Test thoroughly**: Ensure cleanup happens correctly
|
||||
4. **Remove the legacy hook**: Delete `{{did-insert}}`, `{{will-destroy}}`, or `{{did-update}}`
|
||||
|
||||
### Performance Benefits
|
||||
|
||||
Modern alternatives provide better performance:
|
||||
|
||||
- **Getters**: Only compute when dependencies change
|
||||
- **@cached**: Memoizes expensive computations
|
||||
- **Modifiers**: Scoped to specific elements, composable
|
||||
- **getPromiseState**: Declarative data loading, automatic cleanup
|
||||
|
||||
### Common Pitfalls to Avoid
|
||||
|
||||
❌ **Don't use `willDestroy()` for cleanup when a modifier would work**
|
||||
❌ **Don't use `@action` + `did-insert` when a getter would suffice**
|
||||
❌ **Don't manually track changes when `@cached` handles it automatically**
|
||||
❌ **Don't forget `registerDestructor` in custom modifiers**
|
||||
|
||||
### Summary
|
||||
|
||||
Modern Ember provides superior alternatives to legacy lifecycle hooks:
|
||||
|
||||
- **Derived Data**: Use getters and `@cached` for reactive computations
|
||||
- **DOM Side Effects**: Use custom modifiers with `registerDestructor`
|
||||
- **Async Data Loading**: Use getPromiseState from warp-drive/reactiveweb
|
||||
- **Better Code**: More testable, composable, and maintainable
|
||||
|
||||
**Never use `{{did-insert}}`, `{{will-destroy}}`, or `{{did-update}}` in new code.**
|
||||
|
||||
Reference:
|
||||
|
||||
- [Ember Modifiers](https://github.com/ember-modifier/ember-modifier)
|
||||
- [warp-drive/reactiveweb](https://github.com/emberjs/data/tree/main/packages/reactiveweb)
|
||||
- [Glimmer Tracking](https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/)
|
||||
Reference in New Issue
Block a user