I've recently spun up my first Ember app using GraphQL, and as I would do when approaching any new functionality in my Ember app, I reached for the community supported addon ember-apollo-client
.
ember-apollo-client
provides a really nice wrapper around everything I'd want to do with the @apollo/client
, without making too many assumptions/ abstractions. It nicely wraps the query
, watchQuery
, and subscribe
methods, and provides a queryManager
for calling those methods, which quite nicely cleans them up for you as well.
Ember traditionally has many ways to set up/ clean up data-fetching methods, and you usually fall into two camps; I find myself choosing a different path almost every time I write an ember app.
1. Use the model
hook
ember-apollo-client
first suggests using your model hook, illustrated here:
// app/routes/teams.js
import Route from '@ember/routing/route';
import query from '../gql/queries/teams';
export class TeamsRoute extends Route {
@queryManager apollo;
model() {
return this.apollo.watchQuery({ query }, 'teams');
}
}
Pros: This method is well supported by the framework, and allows for utilizing error
and loading
substates to render something while the model is reloading.
Drawbacks: query parameters
. Say we have a sort
parameter. We would then set up an additional observable
property within our model hook, and likely use the setupController
hook to set that on our controller for re-fetching data when sort
changes. This is fine, but includes extra code which could become duplicative throughout your app; leading to potential bugs if a developer misses something.
2. Utilize ember-concurrency
Based on a suggestion I found while digging through their issues and documentation, I gave ember-concurrency
a shot:
// app/routes/teams.ts
import Route from '@ember/routing/route';
export class TeamsRoute extends Route {
setupController(controller, model) {
controller.fetchTeams.perform();
}
resetController(controller) {
controller.fetchTeams.cancelAll();
unsubscribe(controller.fetchTeams.lastSuccessful.result);
}
}
// app/controllers/teams.js
import Controller from '@ember/controller';
import query from '../gql/queries/teams';
export class TeamsController extends Controller {
@queryManager apollo;
@tracked sort = 'created:desc';
@task *fetchTeams() {
const result = yield this.apollo.watchQuery({
query,
variables: { sort: this.sort }
});
return {
result,
observable: getObservable(result)
};
}
@action updateSort(key, dir) {
this.sort = `${key}:${dir}`;
this.fetchTeams.lastSuccessful.observable.refetch();
}
}
Pros: This feels a little more ergonomic. Within the ember-concurrency
task fetchTeams
, we can set up an observable which will be exposed via task.lastSuccessful
. That way, whenever our sort property changes, we can access the underlying observable and refetch
.
ember-concurrency
also gives us some great metadata and contextual state for whether our task's perform
is running, or if it has errored, which allows us to control our loading/ error state.
Drawbacks: In order to perform, and subsequently clean this task up properly, we're going to need to utilize the route's setupController
and resetController
methods, which can be cumbersome, and cleanup especially is easily missed or forgotten.
This also requires the developer writing this code to remember to unsubscribe
to the watchQuery. As the controller is a singleton, it is not being torn down when leaving the route, so the queryManager unsubscribe will not be triggered. Note: if this is untrue, please let me know in the comments!
Either way, we will still need to cancel the task. This is a lot to remember!
Enter @use
Chris Garrett (@pzuraq) and the Ember core team have been working towards the @use
API for some time now. Current progress can be read about here.
While @use
is not yet a part of the Ember public API, the article explains the low-level primitives which, as of Ember version 3.25+, are available to make @use
possible. In order to test out the proposed @use
API, you can try it out via the ember-could-get-used-to-this
package.
⚠️ Warning -- the API for
@use
andResource
could change, so keep tabs on the current usage!
How does this help us?
Remember all of those setup/ teardown methods required on our route? Now, using a helper which extends the Resource
exported from ember-could-get-used-to-this
, we can handle all of that.
Lets go ts
to really show some benefits we get here.
// app/routes/teams.ts
import Route from '@ember/routing/route';
export class TeamsRoute extends Route {}
// app/controllers/teams.ts
import Controller from '@ember/controller';
import { use } from 'ember-could-get-used-to-this';
import GET_TEAMS from '../gql/queries/teams';
import { GetTeams } from '../gql/queries/types/GetTeams';
import { WatchQuery } from '../helpers/watch-query';
import valueFor from '../utils/value-for';
export class TeamsController extends Controller {
@tracked sort = 'created:desc';
@use teamsQuery = valueFor(new WatchQuery<GetTeams>(() => [{
GET_TEAMS,
variables: { sort: this.sort }
}]));
@action updateSort(key, dir) {
this.sort = `${key}:${dir}`;
}
}
And voila! No more setup/ teardown, our WatchQuery
helper handles all of this for us.
Note:
valueFor
is a utility function which helps reflect the type of the "value" property exposed on the Resource. More on that below. This utility should soon be exported directly fromember-could-get-used-to-this
.
So whats going on under the hood?
// app/helpers/watch-query.ts
import { tracked } from '@glimmer/tracking';
import { Resource } from 'ember-could-get-used-to-this';
import { queryManager, getObservable, unsubscribe } from 'ember-apollo-client';
import { TaskGenerator, keepLatestTask } from 'ember-concurrency';
import ApolloService from 'ember-apollo-client/services/apollo';
import { ObservableQuery, WatchQueryOptions } from '@apollo/client/core';
import { taskFor } from 'ember-concurrency-ts';
type QueryOpts = Omit<WatchQueryOptions, 'query'>;
interface WatchQueryArgs {
positional: [DocumentNode, QueryOpts];
}
export class WatchQuery<T> extends Resource<WatchQueryArgs> {
@queryManager declare apollo: ApolloService;
@tracked result: T | undefined;
@tracked observable: ObservableQuery | undefined;
get isRunning() {
return taskFor(this.run).isRunning;
}
get value() {
return {
result: this.result,
observable: this.observable,
isRunning: this.isRunning,
};
}
@keepLatestTask *run(): TaskGenerator<void> {
const result = yield this.apollo.watchQuery<T>(this.args.positional[0]);
this.result = result;
this.observable = getObservable(result);
}
setup() {
taskFor(this.run).perform();
}
update() {
this.observable?.refetch(
this.args.positional[0].variables
);
}
teardown() {
if (this.result) {
unsubscribe(this.result);
}
taskFor(this.run).cancelAll({ resetState: true });
}
}
Lot going on, lets break it down:
We've brought in some libraries to help with using typescript
, including ember-concurrency-ts
.
The Resource
class gives us a way to perform our task upon initialization:
setup() {
taskFor(this.run).perform();
}
And a way to clean up after ourselves when we're done:
teardown() {
if (this.result) {
unsubscribe(this.result);
}
taskFor(this.run).cancelAll({ resetState: true });
}
And remember how we declaratively called refetch
after updating sort? Well, now we can utilize ember's tracking system, since we passed sort
in the constructor function, it should reliably trigger the update
hook if updated:
update() {
this.observable?.refetch(
this.args.positional[1].variables
);
}
Where do we go from here
From here, you can use the same paradigm to build out Resources for handling apollo.subscribe
and apollo.query
, with few code changes.
As our app is very new, we plan on tracking how this works for us over time, but not having to worry about setting up/ cleaning up queries for our application should greatly improve the developer experience right off the bat.
An important thing to note, this article focuses on wrapping the ember-apollo-client
methods, but can Easily be extrapolated to support any data-fetching API you want to use, including Ember Data.
Thanks for reading! Please let me know what ya think in the comments 👋
Top comments (6)
Great writeup! There's some discussion on the future API for the @use library and I think you perspective could be really helpful. Here's the issue: github.com/pzuraq/ember-could-get-...
There's discussion around how to handle updates to args -- should the
update
hook be retired and instead new instances of the resource be created? Love to hear your thoughts on it!Thank you, and absolutely, I'll leave a comment there with our initial experience!
Great job, but i think you could add the case where you fetch data from some component to show a way more easy to understand a see the benefits of this to people that are new in Ember, or came from another frameworks and want to see whats ember have to offer, or just simply prefer to fetch data in the components and avoid controllers.
Agree here, there is likely also an opportunity to illustrate using the resource entirely in the template as well. Resources are truly a powerful paradigm
Shouldn't
updateSort
in the @use example not call refetch since it's settingsort
and the resource updates on that value?Yep, you're totally right -- the
update
method within the resource will be called with the params update. I'll make the update!