Clicking on a button stucks until page is finished rendering

Hi guys,

Let’s say I have a page containing a button and a component and I want to modify the state of the component based on the click event of the button. The way I’m doing it is having a shared property in controller which gets updated on the button click event and is also passed onto the component as a parameter. Inside the component, didRecieveAttrs() calls some methods to update component state.

The problem is that the updation of component state inside didRecieveAttrs() takes a lot of time (some heavy computation involved). So, when I click on the button it gets stuck until the component is reloaded. Is there any way I can introduce a loading state to the component so that it’s not stuck on the click event.

Absolutely. Have you seen ember-concurrency? It is uniquely suited for this very situation.

Additionally, if you want a lighter-weight solution that ember-concurrency (which is great, but a lot of code! Tradeoffs!) you can use something like the load helper/utility and AsyncData described in this post and available here. You could just create a promise during the click that gets resolved, and tie the state of the button to whether the promise is in the isLoading state or is isLoaded or isError.

As an aside: as you start thinking about Octane, it’s worth thinking about ways besides didReceiveAttrs to structure this kind of thing. One big upside of either Ember Concurrency or the AsyncData type is that you can just derive the state you need from the arguments you pass to the component using getters and arguments, instead of trying to set it using the lifecycle hooks.

To put that another way: whether using Ember Concurrency or that helper, you’ll want to think about modeling the behavior of the component as derived from the arguments you pass into it. You can imagine doing something kind of like this:

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

export default class SomeController extends Controller {
  @tracked computationRunning = false;
  
  @action startComputation() {
    this.computationRunning = true;
  }
  
  @action finished() {
    this.computationRunning = false;
  }
}
<!-- app/templates/some-controller.hbs -->
<button
  class={{if this.computationRunning "already-running"}}
  {{on "click" this.startComputation}}
>
  Start computation
</button>

<ComputationComponent
  @shouldDoComputation=this.computationRunning
  @onCompleteComputation=this.finished
/>
// app/components/computation-component.js
import Component from '@glimmer/component';
import { load } from 'app/helpers/load';

function doTheCalculation(onCompletion) {
  return new Promise((resolve) => {
    // your long-running calculation...
    resolve(theValuesYouCalculate);
  }).finally(onCompletion);
}

export default class ComputationComponent extends Component {
  _previousResults;

  get results() {
    if (this.args.shouldDoComputation) {
      this._previousResults = load(
        doTheCalculation(this.args.onCompleteComputation)
      );
    }

    return this._previousResults;
  }
}
<!-- app/components/computation-component.hbs -->
{{#if this.results.isLoaded}}
  {{this.results.value}}
{{else if this.results.isLoading}}
  <LoadingSpinner />
{{else if this.results.isError}}
  <p>Whoops, something went wrong, please try again!</p>
{{/if}}

Here’s how this works:

  1. The only state we really have to track to make this work is a simple boolean property. If you have more complex state that the computation depends on, you can set that via the action as well, of course!
  2. When you press the button, you update that root state, the boolean. When the computation finishes, it can signal to its parent that it is complete using the action passed in.
  3. That would trigger an infinite loop if we did it synchronously, but using a Promise (and the load utility which creates an AsyncData for the Promise) means it won’t happen synchronously, even if your computation was instantaneous, so this is safe.
  4. The #previousResults private class field caches the results of the computation. This lets you just return the previous value you computed once you set the flag for whether the computation is running back to false, and run a new computation whenever it is set to true again.
  5. The AsyncData type returned by load provides isLoading, isLoaded, and isError values so that your template can present something useful while the Promise is resolving or if it rejects.

Note that for Ember Classic, you can make all of these exact same techniques work in the same way. You’ll use classic computed properties and Ember’s set function to set the root state instead, but the same principles work just fine!

Thanks sukima. Will check out ember-concurrency.

Thanks chriskrycho. I think the crux here is that, you can leverage getter method being dependent on the controller property (shouldDoComputation), and let ember handle the re-rendering of template based on IsLoading property from the component. This is something that I was confused about, what I was doing instead is doing something similar but everything inside didRecieveAttrs(). Your approach looks much cleaner and concise.