Ember Controllers — Route-Specific Logic and State
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
- Using controllers when a component would suffice. Controllers are for route-specific state. Reusable logic belongs in components or services.
- Storing mutable state in query params. Query params sync with the URL. Complex objects should not be query params.
- Forgetting to call
reset()on exit. Controllers persist between transitions. Clean up temporary state inreset(). - Mutating the model directly in the controller. The model should be read-only in the controller. Use route actions for mutations.
- Creating controllers for every route. Only create controllers when you need query params or additional state beyond the model.
Practice Questions
- When should you use a controller in Ember?
- How do query parameters work with controllers?
- What is the difference between controller state and service state?
- How do you clean up controller state when leaving a route?
- Challenge: Create a
productscontroller 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
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