Route lifecycle: @bindable vs @observable

Look’s like changing @bindable property value until bind would not lead to {property}Changed invocation at all.
I have a route with a parameter, for example :id. When id is changed (doesn’t matter, via routing, gui or in some other way) I need to load some data. Consider following vm:

export class MiracleCustomElement
{
  @bindable
  id: string;

  activate({ id }: { id: string })
  {
    this.id = id;
  }

  idChanged(cur: string, prev: string)
  {
    // some very important magic here
  }
}

When the property is decorated with @bindable, on the first activate method idChanged is not invoked at all. On subsequent activate calls on route changes it works well. Of course replace activation strategy breaks that.

When the property is decorated with @observable, everything work well. But observable properties cannot be used in bindings in views between custom elements.

Also property cannot be both @observable and @bindable.
This works just like bindable:

  @bindable
  @observable
  id: string;

And this throws ERROR [app-router] TypeError: can't redefine non-configurable property "id":

  @observable
  @bindable
  id: string;

So if I want both property being bindable and {property}Changed being called during activate (more precisely - before bind) the only solution I see is to create two properties (one bindable and one observable) and sync them manually.

Is there any other options? Am I missing something?

1 Like

I’ve only needed to use @bindable when I want to do something like <my-element id.bind="...">.
Since you’re using activate, I’m guessing that this is not what you’re doing? Custom elements do not have an activate and the difference you’re describing is probably due to when the Changed delegate is hooked up or designed to be triggered.
@observable basically applies immediately and can even trigger before the constructor.
Your description sounds like @bindable is set-up once, probably at bind, and then kept alive, since VMs are singletons by default. I imagine adding @transient will break things too.

“But observable properties cannot be used in bindings in views between custom elements.” - Why not? Could you provide an example with the issues you’ve encountered? I might be misunderstanding you.

A potential solution for you, though, might be to just call idChanged(id) in the activate, if you must use @bindable as you’ve described. This will probably lead to duplicate calls on subsequent page loads.

1 Like

FYI Difference between @observable and @bindable

2 Likes

A potential solution for you, though, might be to just call idChanged(id) in the activate

When VM is reused, changes to all @bindable propertied invokes {proeprty}Changed handlers. So in that case I need to control it by myself to prevent two subsequent call - one made by aurelia and one made by myself. Also I need to check it by myself if value actually changed.

1 Like

Yep, I’ve read this. And this does not answer my question at all.

I don’t know why changes to @bindable properties on activate stage does not lead to {property}Changed handler call when VM just created and was not bound yet. Maybe this was done on purpose.

But when VM was bound and is reused, on same activate stage changes became to lead to handler’s calls. Different behavior on same stage. However this is desired behavior.

Actually, most frustrating is that on fresh unbound VM handler is not called.

So possible workarounds are either use one @observable property and one @bindable and synchronize them manually, or as @Krussicus suggested, keep track weither VM was bound or not, and if it wasn’t, call handler manually.

However both workarounds looks like kind of complications for pretty simple scenario.

1 Like

It’s not really a solution I’d recommend. Just an option.
I’m still not clear on why you need a @bindable within a VM. What does your HTML look like?

1 Like

Here is simplified example of what I have in my app. Everything else is a default webpack ts app.

When form or grid opens for the first time, data get not loaded as {property}Changed handler is not invoked.

So the main difficulty is that I need to track my myself, would or would not {property}Changed be called. For example, be adding boolean property, that I set to true in bind(). And if it would not be called, call it by myself. But this is not reliable as behavior could be changed.

Another option is to add something like @observable propObs:string.
In propChanged set this.propObs = this.prop.
In propObsChanges set this.prop = this.propObs and start actually data load.
So prop property will be for templating, propObs for actual data management.
This is looks more reliable but is kind of excess.

form.html

<template>
  <require from="./grid"></require>
  <!-- way to change id via ui - explicitly bound to input -->
  <div>form for <input value.bind="id & debounce:500" /></div>
  <div>
    <!-- way to change id via routing, kind of paging -->
    <a if.bind="id.length > 1" route-href="route: form; params.bind: { id: id.substring(0, id.length - 1) }">prev</a>
    <span else>prev</span>
    <a route-href="route: form; params.bind: { id: id + (id.length + 1) }">next</a>
  </div>
  <br />
  <template if.bind="data || loading">
    <div>ID is ${loading ? '...' : data.id}</div>
    <div>Date is ${loading ? '...' : data.date}</div>
  </template>
  <template else>
    <div>nothing found</div>
  </template>
  <br />
  <div><a route-href="route: grid; params.bind: { q: id }">open full grid</a>(grid data will not load)</div>
  <some-grid query.to-view="id" limit="3"></some-grid>
</template>

form.ts

import { bindable, autoinject } from "aurelia-framework";
import { Router } from "aurelia-router";
import { SomeExternalLoader } from "loader";
import { Logger } from "aurelia-logging";

@autoinject
export class SomeFormCustomElement {
  @bindable id: string;
  @bindable loading: boolean;
  @bindable data: { id: string, date: Date };

  constructor(private router: Router, private external: SomeExternalLoader, private log: Logger) {
    this.log.id = 'form';
  }

  activate({ id }: { id: string }) {
    this.log.info('activated');
    this.id = id;
  }

  async idChanged(cur: string, prev: string) {
    this.log.info(`id changed from ${prev} to ${cur}`);
    this.router.navigateToRoute('form', { id: this.id });
    this.loading = true;
    const data = await this.external.formData(this.id);
    if (cur === this.id) {
      this.data = data;
      this.loading = false;
    }
  }
}

grid.html

<template>
  <div>search for <input value.bind="query & debounce:500" /></div>
  <template if.bind="loading">
    <li><ul repeat.for="item of query.length">...</ul></li>
  </template>
  <template else>
    <li><ul repeat.for="item of data">${item}</ul></li>
  </template>
</template>

grid.ts

import { bindable } from "aurelia-templating";
import { autoinject } from "aurelia-framework";
import { SomeExternalLoader } from "loader";
import { Logger } from "aurelia-logging";

@autoinject
export class SomeGridCustomElement {
  @bindable query: string;
  @bindable limit: number;
  @bindable loading: boolean;
  @bindable data: string[];

  constructor(private external: SomeExternalLoader, private log: Logger) {
    this.log.id = 'grid';
  }

  activate({ q }: { q: string }) {
    this.log.info('activated');
    this.limit = undefined;
    this.query = q;
  }

  async queryChanged(cur: string, prev: string) {
    this.log.info(`query changed from ${prev} to ${cur}`);
    this.loading = true;
    const data = await this.external.gridData(this.query, this.limit);
    if (cur === this.query) {
      this.data = data;
      this.loading = false;
    }
  }
}

loader.ts

export class SomeExternalLoader {
  public formData(id: string): Promise<{ id: string, date: Date }> {
    return this.delay(id.length > 5 ? null : { id, date: new Date() }, 700);
  }

  public gridData(query: string, limit: number): Promise<string[]> {
    return this.delay(query.split('').slice(0, limit).map(x => `${x} - data`), 1200);
  }

  private delay<T>(data: T, ms: number): Promise<T> {
    return new Promise(r => setTimeout(() => r(data), ms));
  }
}

app.html

<template>
  <div><a href="/">home</a> (form data will not load)</div>
  <br />
  <router-view></router-view>
</template>

app.ts

import { PLATFORM } from "aurelia-pal";
import { Router, RouterConfiguration } from 'aurelia-router';

export class App {
  public router: Router;

  public configureRouter(config: RouterConfiguration, router: Router) {
    config.map([
      { route: '', redirect: '/form/1234' },
      { route: '/form/:id', name: 'form', moduleId: PLATFORM.moduleName('./form'), title: 'Form' },
      { route: '/grid/:q',  name: 'grid', moduleId: PLATFORM.moduleName('./grid'), title: 'Grid' },
    ]);

    this.router = router;
  }
}
1 Like

I see. You’re using your ./grid as both an element and a VM?
I’ve never really tried this approach. I haven’t seen it before either. I’m not sure how recommended it is, probably for these reasons.
Consider creating another VM to act as a view space for your grid route instead of trying to re-use your element. Then use @bindable within the grid element and use @observable within your VMs.
That’s usually the approach I take. There are more exotic options but I’ve not field tested those.

2 Likes

Yep, wrapping grid into another element works, @observable can be used on topmost elements and @bindable on child. This looks like most elegant solution.
Thanks.

However I still have no clue, why @bindable is not tracked until bind()?

1 Like

You’ll have to ask the Aurelia team. I can only speculate. Seems like a design decision, i.e. there are two options because of their intended usage.
Makes sense that @bindable would be geared towards the binding process while @observable is more for standard property observation.

1 Like

Yep, that makes sense, right.

However with templates everything work fine out of the box. You do not have to do any workarounds. All changes reflected well regardless of whether the VM is fresh or reused. It just works.

Also, I can expect handler would NOT be called until component is bound. All changes handled on the next ‘tick’, that is opposite to @observable with immediate calls. And nothing does start ‘ticking’ until everything is bound. So, until bind() - no {property}Changed calls. This is fine.

But I expect changes to be tracked since the very beginning, and WHEN component got bound and that ‘ticking’ begins, corresponding {property}Changed handlers would be called. Of course, only in case of any changes happened before.

Don’t know, maybe it would be better to create an issue on github? Hope folks from Aurelia team are here :slight_smile:

1 Like

What would you like to accomplish with @bindable that requires earlier tracking?
Afaik, it’s usually used in property.bind scenarios, which, again afaik, only get assigned during the bind life-cycle. I’d argue that there might be alternatives.

For instance, if the data you want in your element is essential before the bind life-cycle, consider using the aurelia-store instead of the property.bind approach. This approach has provided me far more flexibility with regards to when data is retrieved.

2 Likes