Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Computed properties (model => model synchronisation) #47553

Closed
pkozlowski-opensource opened this issue Sep 27, 2022 · 13 comments
Closed

Computed properties (model => model synchronisation) #47553

pkozlowski-opensource opened this issue Sep 27, 2022 · 13 comments
Labels
area: core Issues related to the framework runtime canonical This issue represents a canonical design issue in Angular. core: change detection P3 An issue that is relevant to core functions, but does not impede progress. Important, but not urgent type: use-case
Milestone

Comments

@pkozlowski-opensource
Copy link
Member

pkozlowski-opensource commented Sep 27, 2022

Which @angular/* package(s) are relevant/related to the feature request?

core

Description

Angular dirty-checking mechanism works well for synchronising (propagating) model changes to the DOM. However, there are legitimate situations where one would like to compute / derive a new model value (from the existing model values) before rendering. Angular has no such concept - even if requested by users (ex.: #20472).

Proposed solution

We don't have any concrete solution proposal at the moment but we are exploring the space and will open a RFC when the solution space becomes clearer. But even without the concrete proposal on a table, there are certain properties that we would like to see in the final solution:

  • semantics of "recompute only if one of the dependencies changed";
  • composability: it should be possible to construct of chain of computed values (computed depending on other computed);
  • great integration in the framework, ex.:
    • @Input - it should be possible to compute new model value based on input values changing over time;
    • convenient syntax for accessing computed values in a component / teamplate.

Alternatives considered

While Angular doesn't have the dedicated concept of the computed properties, there are several existing mechanisms that can serve the purpose (although with different caveats). Here is an overview of the possible approaches that work today.

Use an external reactivity system

Many Angular users integrate RxJS, or similar push-based reactivity systems, into their template rendering pipeline. Most (all?) of the push-based reactivity systems have a concept of deriving values (ex. combinelatest in RxJS).

Use memoization

We could introduce a simple memo utility function. A memo function could just compare previous / new arguments and execute re-computation when needed, similar to what the pure pipe does today. Ex. (taken from #46135):

export class AppComponent {
  fruit = 'banana';
  idFruit = 0;

  getNameFruitUrl = memo((name: string) => `/fruits/${name}.jpg`);

  getIdFruitUrl = memo(() => `/fruits/${this.idFruit}.jpg`, () => [this.idFruit]);

  getLargeFruitUrl = memo((large?: boolean) => `/${large ? 'large' : 'small'}/${this.fruit}.jpg`,
    () => [this.fruit]
  );
}

Here is a working stackblitz: https://stackblitz.com/edit/angular-ivy-txzpmf?file=src%2Fapp%2Fapp.component.ts,src%2Fapp%2Fmemo.ts

Existing issues / PRs

The idea of computed properties was discussed in the past in the issue tracker - most notably in #20472 (which generated number of comments and ~70 upvotes).

@th0r
Copy link

th0r commented Sep 27, 2022

getIdFruitUrl = memo(() => '/fruits/${this.idFruit}.jpg', () => [this.idFruit]);

I would strongly vote agains React's useEffect-like manual list of dependencies - it's a road to the dead-end as DX of this approach is terrible and this list is very difficult to keep in sync with the code in the memoized function.

Deps should be collected automatically just like in the majority of reactive frameworks/libs.

@pkozlowski-opensource
Copy link
Member Author

@th0r the memoization approach was listed in the "alternatives" section as something you can do today to achieve similar effects. It is not part of the actual proposal.

@th0r
Copy link

th0r commented Sep 27, 2022

Ah, ok, sorry then.

@maxime1992
Copy link

In my opinion, having a decorator that turns an input into an observable would be fantastic.

It would:

  • Integrate very well with the rest of the API that uses observables (like router and http)
  • Help with zoneless CD. If everything is an observable, you know exactly when to trigger change detection. When the async pipe or equivalent (ngrxPush pipe and co) receive a value, you know a CD cycle is needed
  • Be easily composable, for example:
@Component(...)
class MyComponent {
  @ObservableInput('input1') input1$!: Observable<number>;

  public input1Updated$: Observable<number> = this.input1$.pipe(map(x => x * 2));
}

Or with a more realistic example:

@Component(...)
class MyComponent {
  @ObservableInput('currentUserId') currentUserId$!: Observable<UserId>;

  public currentUser$: Observable<User | null> = this.currentUserId$.pipe(
    startWith(null),
    switchMap((currentUserId) =>
      concatMap(
        of(null),
        this.http.get(`https://my-api/users/${currentUserId}`)
      )
    )
  );

  constructor(http: HttpClient) {}
}

The only point I can think of that'd need a bit more thinking would be how to get an equivalent of ngOnChanges because if you've got plenty of inputs, and during a CD cycle you change them all, you do not want to use all the individual observables with a combineLatest as it'd fire quite a few times. An easy workaround would be to use combineLatest but with a debounceTime for example but DX wouldn't be that good.

And with this, would come the question of being type safe etc.

A possible alternative to that solution (for the previous example) would be something like:

@Component()
class MyComponent {
  @InputChanges() changes$!: Observable<{
    userId: UserId;
    prop2: any;
    prop3: any;
  }>;

  public currentUser$: Observable<User | null> = this.changes$.pipe(
    map((c) => c.userId),
    distinctUntilChanged(),
    startWith(null),
    switchMap((currentUserId) =>
      concatMap(
        of(null),
        this.http.get(`https://my-api/users/${currentUserId}`)
      )
    )
  );

  constructor(http: HttpClient) {}
}

@antischematic
Copy link

antischematic commented Sep 27, 2022

I think any discussion of a reactivity system in Angular needs to consider more than computed properties. What if a child component injects a parent component with computed properties? Can a service have computed properties? What about parameterised computed properties? What about computed properties for content and view queries? How do we call effects reactively based on any property (or computed property)?

Vue uses proxy objects to achieve this. With decorators you can swap the this context with a proxy object for dependency tracking. Then you can have reactive functions that get called each time those dependencies change.

Vue has a composition API. This lets you group related logic together with hooks for initializing and tearing down effects. In Angular this could be achieved with a configurable EnvironmentInjector to wrap decorated methods in an injection context. This would allow us to extract logic into reusable functions without introducing functional components.

@Component()
export class UIComponent {
   @Input() userId!: string

   todos: Todo[] = []

   // memoized until `todos` changes
   @Select() get remaining() {
      return this.todos.filter(todo => !todo.completed)
   }

   // this function runs in its own `EnvironmentInjector` context
   // it is called on the first change detection run and whenever `userId` changes
   // it can also be called imperatively
   @Invoke() loadTodos() {
      // dependency injection
      const loadTodos = inject(HttpClient).get(endpoint, {
         params: { userId: this.userId }
      })

      // hook for configuring merge strategy
      useOperator(switchAll())

      // run some effects
      // each call cancels the previous effect (ie: switchAll)
      dispatch(loadTodos, todos => {
         this.todos = todos
      })
   }
}

Reference: https://github.com/antischematic/angular-state-library

@bryanrideshark
Copy link

bryanrideshark commented Sep 28, 2022

I maintain an enterprise Angular application with more than 200 libraries and 60 component driven pages. We utilize almost every feature available in Angular, but the last thing I would want is application state managed by Angular itself.

I found myself doing a lot of this type of thing:


export class SampleComponent {
  
  private _tripPostKey$ = new BehaviorSubject<string>('');
  @Input()
  get tripPostKey(): string {
    return this._tripPostKey$.value;
  }
  set tripPostKey(value: string) {
    this._tripPostKey$.next(value);
  }
}

So much so, that we developed a decorator to add in some sugar.


export class SampleComponent {
  
  @ObservablePropertyAccessor()
  private _tripPostKey$ = new BehaviorSubject<string>('');
  @Input()
  tripPostKey: string;
}

For 'computed properties', we just use get someProperty() {} as it's built into the language.

I think rather than adding a "Computed Properties" DSL, using proxies or decorators to instruct change detection that fields have changed would be useful. Instead of a ChangeDetectionStrategy.OnPush, it could be ChangeDetectionStrategy.Computed, which would add the proxy instrumentation and learn about changes through whichever algorithm Vue is using.

@bryanrideshark
Copy link

I think any discussion of a reactivity system in Angular needs to consider more than computed properties. What if a child component injects a parent component with computed properties? Can a service have computed properties? What about parameterised computed properties? What about computed properties for content and view queries? How do we call effects reactively based on any property (or computed property)?

Vue uses proxy objects to achieve this. With decorators you can swap the this context with a proxy object for dependency tracking. Then you can have reactive functions that get called each time those dependencies change.

Vue has a composition API. This lets you group related logic together with hooks for initializing and tearing down effects. In Angular this could be achieved with a configurable EnvironmentInjector to wrap decorated methods in an injection context. This would allow us to extract logic into reusable functions without introducing functional components.

@Component()
export class UIComponent {
   @Input() userId!: string

   todos: Todo[] = []

   // memoized until `todos` changes
   @Select() get remaining() {
      return this.todos.filter(todo => !todo.completed)
   }

   // this function runs in its own `EnvironmentInjector` context
   // it is called on the first change detection run and whenever `userId` changes
   // it can also be called imperatively
   @Invoke() loadTodos() {
      // dependency injection
      const loadTodos = inject(HttpClient).get(endpoint, {
         params: { userId: this.userId }
      })

      // hook for configuring merge strategy
      useOperator(switchAll())

      // run some effects
      // each call cancels the previous effect (ie: switchAll)
      dispatch(loadTodos, todos => {
         this.todos = todos
      })
   }
}

Reference: https://github.com/antischematic/angular-state-library

I think services can have computed properties - Javascript has class getters, and there are libraries such as rxjs which provide excellent reactivity. Services are just classes - Angular doesn't need to invent an in-house state management machine, it just needs to optimize the path to efficiently mutating the DOM; That's what Angular is there for.

@alxhub alxhub added the P3 An issue that is relevant to core functions, but does not impede progress. Important, but not urgent label Nov 16, 2022
@ngbot ngbot bot modified the milestones: needsTriage, Backlog Nov 16, 2022
@Akxe
Copy link

Akxe commented Nov 24, 2022

I am missing a possibility to add an input transformer to ObservablePropertyAccessor. I know that for your case it is enough, but it would be nice in the official supported version, along with the removal of the almost duplicate @Input()...

export class SampleComponent {
  
  @ObservablePropertyAccessor()
  private _tripPostKey$ = new BehaviorSubject<string>('');
  @Input()
  tripPostKey: string;
}

@pkozlowski-opensource
Copy link
Member Author

We do have computed properties with signals now!

@Akxe
Copy link

Akxe commented Nov 16, 2023

@pkozlowski-opensource would you share an example?

@pkozlowski-opensource
Copy link
Member Author

@Akxe
Copy link

Akxe commented Nov 16, 2023

Oh, you're right... my last comment was about observable inputs, so I got confused. You are right computed signals are the fix to this issue :)

@angular-automatic-lock-bot
Copy link

This issue has been automatically locked due to inactivity.
Please file a new issue if you are encountering a similar or related problem.

Read more about our automatic conversation locking policy.

This action has been performed automatically by a bot.

@angular-automatic-lock-bot angular-automatic-lock-bot bot locked and limited conversation to collaborators Dec 17, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area: core Issues related to the framework runtime canonical This issue represents a canonical design issue in Angular. core: change detection P3 An issue that is relevant to core functions, but does not impede progress. Important, but not urgent type: use-case
Projects
None yet
Development

No branches or pull requests

7 participants