Skip to content

Ember Controllers — Route-Specific Logic and State

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about Ember Controllers. We cover key concepts, practical examples, and best practices to help you master this topic.

Ember controllers Decorate the model data from route handlers with additional state and actions. They manage query parameters, hold temporary UI state, and provide computed properties that derive data from the model.

What You'll Learn

You will learn when to use controllers, how to manage query parameters, define controller actions, create computed properties, and clean up state on route exit.

Why It Matters

Controllers bridge the gap between route data and template display. They keep route handlers focused on data loading and templates focused on rendering, while controllers handle the in-between logic.

Real-World Use

A search results page uses a controller to manage the search query, sort order, page number, and filter selections as query parameters. The controller also computes pagination info and derived display data from the model.

flowchart LR
    A[Route Handler] -->|model| B[Controller]
    B -->|queryParams| C[URL]
    B -->|computed| D[Template]
    B -->|actions| E[User Events]
    E --> B
    C --> B

When to Use Controllers

In modern Ember (Octane+), controllers are optional. Use them when you need:

  • Query parameters
  • Transient UI state that does not belong in a service
  • Actions that modify the current route's data
// app/controllers/search.js
import Controller from '@ember/controller';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class SearchController extends Controller {
  // Query parameters
  queryParams = ['query', 'page', 'sort', 'category'];

  @tracked query = '';
  @tracked page = 1;
  @tracked sort = 'relevance';
  @tracked category = '';

  // Computed properties
  get hasQuery() {
    return this.query && this.query.length > 0;
  }

  get totalPages() {
    return Math.ceil((this.model?.meta?.total || 0) / 20);
  }

  get hasPreviousPage() {
    return this.page > 1;
  }

  get hasNextPage() {
    return this.page < this.totalPages;
  }

  get pageInfo() {
    return `Page ${this.page} of ${this.totalPages}`;
  }

  get sortOptions() {
    return [
      { value: 'relevance', label: 'Relevance' },
      { value: 'date_desc', label: 'Newest First' },
      { value: 'date_asc', label: 'Oldest First' },
      { value: 'title', label: 'Alphabetical' }
    ];
  }

  // Actions
  @action
  search(newQuery) {
    this.query = newQuery;
    this.page = 1;
  }

  @action
  goToPage(pageNum) {
    this.page = pageNum;
  }

  @action
  previousPage() {
    if (this.hasPreviousPage) {
      this.page--;
    }
  }

  @action
  nextPage() {
    if (this.hasNextPage) {
      this.page++;
    }
  }

  @action
  changeSort(newSort) {
    this.sort = newSort;
    this.page = 1;
  }
}

Setting Up Query Parameters

Query parameters in the controller sync with the URL automatically.

// app/controllers/products.js
import Controller from '@ember/controller';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class ProductsController extends Controller {
  // Define query params
  queryParams = [
    'category',
    'minPrice',
    'maxPrice',
    'sortBy',
    'page',
    'inStock'
  ];

  // Default values — reset when not in URL
  @tracked category = null;
  @tracked minPrice = null;
  @tracked maxPrice = null;
  @tracked sortBy = 'name';
  @tracked page = 1;
  @tracked inStock = false;

  get activeFilters() {
    let filters = [];

    if (this.category) filters.push(`Category: ${this.category}`);
    if (this.minPrice) filters.push(`Min: $${this.minPrice}`);
    if (this.maxPrice) filters.push(`Max: $${this.maxPrice}`);
    if (this.inStock) filters.push('In Stock Only');

    return filters;
  }

  get hasActiveFilters() {
    return this.activeFilters.length > 0;
  }

  @action
  clearFilters() {
    this.category = null;
    this.minPrice = null;
    this.maxPrice = null;
    this.inStock = false;
    this.page = 1;
  }

  @action
  removeFilter(filterType) {
    this[filterType] = null;
    this.page = 1;
  }
}

Controller Lifecycle

// app/controllers/settings.js
import Controller from '@ember/controller';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class SettingsController extends Controller {
  @tracked hasUnsavedChanges = false;

  // Called when entering the route
  init() {
    super.init(...arguments);
    console.log('Settings controller initialized');
  }

  // Called when model changes
  modelUpdated() {
    console.log('Model updated');
  }

  // Clean up when leaving the route
  reset() {
    this.hasUnsavedChanges = false;
  }

  @action
  saveSettings() {
    this.model.save();
    this.hasUnsavedChanges = false;
  }

  @action
  confirmLeave() {
    if (this.hasUnsavedChanges) {
      return window.confirm('You have unsaved changes. Leave anyway?');
    }
    return true;
  }
}

Controller with Service Injection

// app/controllers/dashboard.js
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class DashboardController extends Controller {
  @service session;
  @service notifications;

  @tracked dateRange = 'week';
  @tracked refreshInterval = null;

  get greeting() {
    let hour = new Date().getHours();
    if (hour < 12) return 'Good morning';
    if (hour < 18) return 'Good afternoon';
    return 'Good evening';
  }

  get filteredData() {
    let data = this.model;
    if (this.dateRange === 'week') {
      return data.slice(-7);
    } else if (this.dateRange === 'month') {
      return data.slice(-30);
    }
    return data;
  }

  @action
  changeDateRange(range) {
    this.dateRange = range;
  }

  @action
  startAutoRefresh() {
    if (this.refreshInterval) return;

    this.refreshInterval = setInterval(() => {
      this.model.reload();
      this.notifications.info('Dashboard data refreshed');
    }, 30000); // Every 30 seconds
  }

  @action
  stopAutoRefresh() {
    if (this.refreshInterval) {
      clearInterval(this.refreshInterval);
      this.refreshInterval = null;
    }
  }

  reset() {
    this.stopAutoRefresh();
    this.dateRange = 'week';
  }
}

Controller vs Component

Knowing when to use a controller versus a component:

Concern Controller Component
Query parameters Yes No
Route-specific state Yes No
Reusable logic No Yes
Template rendering No Yes
Actions that affect route Yes Indirectly
Testing isolation Moderate Full

Without Controllers (Template-Only)

When you do not need query params or additional state, skip the controller:

{{! app/templates/posts.hbs — no controller needed }}
<h1>Posts</h1>
<ul>
  {{#each this.model as |post|}}
    <li>{{post.title}}</li>
  {{/each}}
</ul>

Testing Controllers

// tests/unit/controllers/search-test.js
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';

module('Unit | Controller | search', function(hooks) {
  setupTest(hooks);

  test('it computed page info correctly', function(assert) {
    let controller = this.owner.lookup('controller:search');
    controller.model = { meta: { total: 50 } };

    assert.equal(controller.totalPages, 3);
    assert.equal(controller.pageInfo, 'Page 1 of 3');

    controller.page = 3;
    assert.ok(controller.hasPreviousPage);
    assert.notOk(controller.hasNextPage);
  });

  test('it resets page on new search', function(assert) {
    let controller = this.owner.lookup('controller:search');
    controller.page = 5;
    controller.search('new query');
    assert.equal(controller.page, 1);
    assert.equal(controller.query, 'new query');
  });
});

Common Mistakes

  1. Using controllers when a component would suffice. Controllers are for route-specific state. Reusable logic belongs in components or services.
  2. Storing mutable state in query params. Query params sync with the URL. Complex objects should not be query params.
  3. Forgetting to call reset() on exit. Controllers persist between transitions. Clean up temporary state in reset().
  4. Mutating the model directly in the controller. The model should be read-only in the controller. Use route actions for mutations.
  5. Creating controllers for every route. Only create controllers when you need query params or additional state beyond the model.

Practice Questions

  1. When should you use a controller in Ember?
  2. How do query parameters work with controllers?
  3. What is the difference between controller state and service state?
  4. How do you clean up controller state when leaving a route?
  5. Challenge: Create a products controller with query parameters for category, price range (min/max), sort order, and page number. Add computed properties for active filters count, pagination info, and sort options. Add actions to filter, sort, paginate, and clear all filters. Wire it up to a route that loads products based on the query params.

FAQ

Are controllers required in Ember Octane?

No. They are optional. Use them only when you need query params or route-specific state.

Can I access controller from a component?

Not directly. Pass controller data as component arguments.

What is the `reset` method?

It is called when the route is exiting. Clean up temporary state there.

Can multiple routes share a controller?

No, each route has its own controller. Share state via services.

How do I set a query param without triggering a route refresh?

Use controller.set('queryParam', value) with { replace: true }.

Mini Project

Create a products listing with full controller support. (1) Query params: category, minPrice, maxPrice, sortBy, page. (2) Computed: activeFilters, totalPages, paginationInfo, sortOptions. (3) Actions: filter, clearFilters, removeFilter, sort, paginate. (4) The route handler uses store.query with the query params. (5) Template shows active filter badges, product grid, and pagination controls.

What's Next

Now that you understand controllers, learn Ember Actions for event handling. Then explore Ember Testing for application testing.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro