Ember Apollo Client + @use

chrismllr

Chris Miller

Posted on April 13, 2021

Ember Apollo Client + @use

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');
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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 and Resource 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}`;
  }
}
Enter fullscreen mode Exit fullscreen mode

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 from ember-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 });
  }
}
Enter fullscreen mode Exit fullscreen mode

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(); 
}
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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
  );
}
Enter fullscreen mode Exit fullscreen mode

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 👋

💖 💪 🙅 🚩
chrismllr
Chris Miller

Posted on April 13, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Ember Apollo Client + @use
graphql Ember Apollo Client + @use

April 13, 2021