Files
marco/.agents/skills/ember-best-practices/rules/component-class-fields.md

7.9 KiB

title, impact, impactDescription, tags
title impact impactDescription tags
Use Class Fields for Component Composition MEDIUM-HIGH Better composition and initialization patterns components, class-fields, composition, initialization

Use Class Fields for Component Composition

Use class fields for clean component composition, initialization, and dependency injection patterns. Tracked class fields should be roots of state - representing the minimal independent state that your component owns. In most apps, you should have very few tracked fields.

Incorrect (imperative initialization, scattered state):

// app/components/data-manager.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';

class DataManager extends Component {
  @service store;
  @service router;

  // Scattered state management - hard to track relationships
  @tracked currentUser = null;
  @tracked isLoading = false;
  @tracked error = null;

  loadData = async () => {
    this.isLoading = true;
    try {
      this.currentUser = await this.store.request({ url: '/users/me' });
    } catch (e) {
      this.error = e;
    } finally {
      this.isLoading = false;
    }
  };

  <template>
    <div>{{this.currentUser.name}}</div>
  </template>
}

Correct (class fields with proper patterns):

// app/components/data-manager.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
import { cached } from '@glimmer/tracking';
import { getPromiseState } from '@warp-drive/reactiveweb';

class DataManager extends Component {
  // Service injection as class fields
  @service store;
  @service router;

  // Tracked state as class fields - this is a "root of state"
  // Most components should have very few of these
  @tracked selectedFilter = 'all';

  // Data loading with getPromiseState
  @cached
  get currentUser() {
    const promise = this.store.request({
      url: '/users/me',
    });
    return getPromiseState(promise);
  }

  <template>
    {{#if this.currentUser.isFulfilled}}
      <div>{{this.currentUser.value.name}}</div>
    {{else if this.currentUser.isRejected}}
      <div>Error: {{this.currentUser.error.message}}</div>
    {{/if}}
  </template>
}

Understanding "roots of state":

Tracked fields should represent independent state that your component owns - not derived data or loaded data. Examples of good tracked fields:

  • User selections (selected tab, filter option)
  • UI state (is modal open, is expanded)
  • Form input values (not yet persisted)

In most apps, you'll have very few tracked fields because most data comes from arguments, services, or computed getters.

Composition through class field assignment:

// app/components/form-container.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { TrackedObject } from 'tracked-built-ins';

class FormContainer extends Component {
  // Compose form state
  @tracked formData = new TrackedObject({
    firstName: '',
    lastName: '',
    email: '',
    preferences: {
      newsletter: false,
      notifications: true,
    },
  });

  // Compose validation state
  @tracked errors = new TrackedObject({});

  // Compose UI state
  @tracked ui = new TrackedObject({
    isSubmitting: false,
    isDirty: false,
    showErrors: false,
  });

  // Computed field based on composed state
  get isValid() {
    return Object.keys(this.errors).length === 0 && this.formData.email && this.formData.firstName;
  }

  get canSubmit() {
    return this.isValid && !this.ui.isSubmitting && this.ui.isDirty;
  }

  updateField = (field, value) => {
    this.formData[field] = value;
    this.ui.isDirty = true;
    this.validate(field, value);
  };

  validate(field, value) {
    if (field === 'email' && !value.includes('@')) {
      this.errors.email = 'Invalid email';
    } else {
      delete this.errors[field];
    }
  }

  <template>
    <form>
      <input
        value={{this.formData.firstName}}
        {{on "input" (pick "target.value" (fn this.updateField "firstName"))}}
      />

      <button disabled={{not this.canSubmit}}>
        Submit
      </button>
    </form>
  </template>
}

Mixin-like composition with class fields:

// app/utils/pagination-mixin.js
import { tracked } from '@glimmer/tracking';

export class PaginationState {
  @tracked page = 1;
  @tracked perPage = 20;

  get offset() {
    return (this.page - 1) * this.perPage;
  }

  nextPage = () => {
    this.page++;
  };

  prevPage = () => {
    if (this.page > 1) this.page--;
  };

  goToPage = (page) => {
    this.page = page;
  };
}
// app/components/paginated-list.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
import { PaginationState } from '../utils/pagination-mixin';

class PaginatedList extends Component {
  // Compose pagination functionality
  pagination = new PaginationState();

  @cached
  get paginatedItems() {
    const start = this.pagination.offset;
    const end = start + this.pagination.perPage;
    return this.args.items.slice(start, end);
  }

  get totalPages() {
    return Math.ceil(this.args.items.length / this.pagination.perPage);
  }

  <template>
    <div class="list">
      {{#each this.paginatedItems as |item|}}
        <div>{{item.name}}</div>
      {{/each}}

      <div class="pagination">
        <button {{on "click" this.pagination.prevPage}} disabled={{eq this.pagination.page 1}}>
          Previous
        </button>

        <span>Page {{this.pagination.page}} of {{this.totalPages}}</span>

        <button
          {{on "click" this.pagination.nextPage}}
          disabled={{eq this.pagination.page this.totalPages}}
        >
          Next
        </button>
      </div>
    </div>
  </template>
}

Shareable state objects:

// app/utils/selection-state.js
import { tracked } from '@glimmer/tracking';
import { TrackedSet } from 'tracked-built-ins';

export class SelectionState {
  @tracked selectedIds = new TrackedSet();

  get count() {
    return this.selectedIds.size;
  }

  get hasSelection() {
    return this.selectedIds.size > 0;
  }

  isSelected(id) {
    return this.selectedIds.has(id);
  }

  toggle = (id) => {
    if (this.selectedIds.has(id)) {
      this.selectedIds.delete(id);
    } else {
      this.selectedIds.add(id);
    }
  };

  selectAll = (items) => {
    items.forEach((item) => this.selectedIds.add(item.id));
  };

  clear = () => {
    this.selectedIds.clear();
  };
}
// app/components/selectable-list.gjs
import Component from '@glimmer/component';
import { SelectionState } from '../utils/selection-state';

class SelectableList extends Component {
  // Compose selection behavior
  selection = new SelectionState();

  get selectedItems() {
    return this.args.items.filter((item) => this.selection.isSelected(item.id));
  }

  <template>
    <div class="toolbar">
      <button {{on "click" (fn this.selection.selectAll @items)}}>
        Select All
      </button>
      <button {{on "click" this.selection.clear}}>
        Clear
      </button>
      <span>{{this.selection.count}} selected</span>
    </div>

    <ul>
      {{#each @items as |item|}}
        <li class={{if (this.selection.isSelected item.id) "selected"}}>
          <input
            type="checkbox"
            checked={{this.selection.isSelected item.id}}
            {{on "change" (fn this.selection.toggle item.id)}}
          />
          {{item.name}}
        </li>
      {{/each}}
    </ul>

    {{#if this.selection.hasSelection}}
      <div class="actions">
        <button>Delete {{this.selection.count}} items</button>
      </div>
    {{/if}}
  </template>
}

Class fields provide clean composition patterns, better initialization, and shareable state objects that can be tested independently.

Reference: JavaScript Class Fields