Skip to content

Explicit Tracking for effect(): Request for Reconsideration #56155

Open

Description

@rainerhahnekamp

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

core

Changelog

June 2, 2024: Added section on relying on external libraryes

Description

We have been refactoring an application towards using Angular's Signals. During this process, we observed a recurring pattern where effects inevitably require the use of untracked. This pattern has revealed several issues:

  1. Frequent Use of untracked: Developers frequently need to use untracked to prevent unintended side effects in signals, making it a standard practice in our codebase.
  2. Error-Prone Nature: The necessity of untracked is not well-known among developers, leading to common errors when it is omitted.
  3. Maintenance Overhead: Constantly ensuring that untracked is correctly applied adds to the maintenance burden and cognitive load on developers.

To address these issues, we propose that Angular effects should adopt an explicit tracking model.

Proposed solution

  1. Explicit Declaration: Developers should explicitly declare dependencies within effects, rather than relying on automatic tracking.
  2. Default Non-Tracking: By default, effects should not track dependencies unless explicitly specified, reducing the need for untracked.

Example Scenario:
Currently, developers write:

id = input.required<number>();

effect(() => {
  // Part 1: Tracking
  const id = this.id();

  // Part 2: Execution
  untracked(() => {
    this.dataService.load(id);
  });
})

With explicit tracking, it would look like:

id = input.required<number>();

effect(this.id, (id) => {
  this.dataService.load(id);
});

Benefits:

  1. Reduced Errors: Explicit tracking minimizes the risk of overlooking untracked, leading to more predictable and stable code.
  2. Improved Developer Experience: Clearer dependency management simplifies understanding and maintaining the code.
  3. Enhanced Performance: Explicit tracking can optimize performance by avoiding unnecessary re-computations and side effects.

Internal API and Library Ecosystem:
It is noteworthy that some parts of the internal Angular API already utilize automatic untracked. While this is beneficial, other libraries face similar problems and would benefit if the untracking responsibility was handled by the caller. This approach can provide more consistent and error-free usage across different codebases and libraries.

** External Libraries:**
It has been suggested that this change be implemented as an external library instead. Most application developers are not aware of explicit tracking, though, and would not expect implicit tracking, leading to bugs. Relying on an external library requires developers to be aware of the problem and actively seek out a solution.

Reference: #56155 (comment)

Prior Discussion:
This issue was previously raised in

Thank you for considering this enhancement.

Alternatives considered

  • Introducing a fourth "effect-like" function with explicit tracking
  • Keep it as it is

Activity

  1. added
    area: coreIssues related to the framework runtime
    core: reactivityWork related to fine-grained reactivity in the core framework
    on May 29, 2024
  2. modified the milestone: needsTriage on May 29, 2024
  3. HaasStefan commented on May 29, 2024

    @HaasStefan

    I like the idea. In my opinion explicit is always better than implicit.

    One syntactical consideration I would suggest is to lean towards the syntax of the useEffect hook in React, just because this might appear more common in the web dev space. Such that the explicit effect code would look like this:

    id = input.required<number>();
    
    effect(() => {
      this.dataService.load(this.id());
    }, [this.id]);
  4. rainerhahnekamp commented on May 29, 2024

    @rainerhahnekamp
    Author

    @HaasStefan, thanks. I don't really mind what the syntax looks like. I'm happy with everything as long as the tracking is explicit.

  5. mauriziocescon commented on May 29, 2024

    @mauriziocescon

    IMO there is also another interesting aspect which was reported in #56098 related to the first run of effects + untracked and the reason why it's a bit special.

    Unfortunately, the ticket doesn't provide an example... so here is one: https://stackblitz.com/edit/stackblitz-starters-lt4skr?file=src%2Fitems.service.ts (Note: I post the example here cause I don't want to interfere with the ticket above)

    Imagine you use a service as a "store" and you want to fetch data depending on a set of parameters. Here is what you might end up with:

    @Injectable({ providedIn: 'root' })
    export class ItemsService {
      private params = signal<{ textSearch: string }>({ textSearch: '' });
    
      private items = signal<{ id: string }[]>([]);
      private loading = signal<boolean>(false);
      private error = signal<string | undefined>(undefined);
    
      private skipCallback = true;
    
      // effects are always executed once: but I actually
      // don't want to fetch data based on the initial value
      // of params... only when updateParams is called.
      //
      // So, I need a check in order to skip
      // the first execution.
      //
      private paramsDidChange = effect(() => {
        this.params();
    
        untracked(() => {
          if (!this.skipCallback) {
            this.loading.set(true);
            this.fetchItems(this.params())
              .then((items) => this.items.set(items))
              .catch((err) => this.error.set(err))
              .finally(() => this.loading.set(false));
          }
          this.skipCallback = false;
        });
      });
    
      // consumers of the service call this public method
      updateParams(params: { textSearch: string }): void {
        this.params.set({ textSearch: params.textSearch });
      }
    
      getItems(): Signal<{ id: string }[]> {
        return this.items.asReadonly();
      }
    
      // other methods to retrieve signals.asReadonly
      // ...
    
      private async fetchItems(params: { textSearch: string }): Promise<{ id: string }[]> {
        // ...
      }
    }

    IMO for such use cases, skipping the first "callback" call (on top of having explicit deps) would be very handy.

    solid (defer): https://www.solidjs.com/tutorial/reactivity_on
    vue (watch, lazy by default): https://vuejs.org/guide/essentials/watchers#eager-watchers

  6. JeanMeche commented on May 29, 2024

    @JeanMeche
    Member

    For what it's worth, explicit dependencies has an issue, you cannot build an implicit effect on top of an explicit effect function. But the opposite is true. You can build an explicitEffect function on top of the existing effect.
    It makes it a prime candidate for a community lib contribution.

    I'll look into landing this in ngxtension/ngxtension-platform#393

  7. mauriziocescon commented on May 29, 2024

    @mauriziocescon

    @JeanMeche always our hero... thanks!

    What the others are doing (just for comparison and inspiration):

    I like the vue approach (new primitive like watch, effect untouched)... but this is not new! 😂

  8. HaasStefan commented on May 29, 2024

    @HaasStefan

    @JeanMeche , IMO it should be part of the core package and the default opposed to an implicit effect and a third party layer on top.

    What about something along these lines:

    // Explicit Tracking:
    effect(() => {
      this.dataService.load(this.id());
    }, [this.id]);
    
    // Implicit Tracking:
    effect(() => {
      this.dataService.load(this.id());
      console.log(untracked(this.someSignal());
    }, { implicitTracking: true });

    People could still use both, but with the function overload and explicit as default, the community would be served well without the need of third party solutions.

  9. rainerhahnekamp commented on May 29, 2024

    @rainerhahnekamp
    Author

    @JeanMeche, from an application developer's perspective I want to be in charge when I write the effect. I want to know what gets tracked. You can always provide an implicit version via overload the effect() (or a 4th function)

    We strongly believe that explicit tracking is what application developers want/intend. Implicit tracking might be an additional option or something which is more relevant for library/framework developers (just guessing).

    As much as I like community projects, I see a strong argument for having it in the core.

  10. msmallest commented on May 29, 2024

    @msmallest
    Contributor

    The necessity of untracked is not well-known among developers, leading to common errors when it is omitted.

    I am onboarding my team onto signals gradually on a new project, and effects in general are one of the more complex topics to explain. The first thing they ask is how and when an effect fires off its side effects, and the implicit tracking of everything by default behavior makes this a hairy issue. I know that the tracking/untracking is a key part in ones usage of effects, but having to explicitly denote something is untracked as the exception is more overhead and even confuses me. For this reason to be honest I shy away from effects for more than console logging, or I only suggest/use them for side effects we are fine with firing off implicitly all the time.

    I think having effects require explicit tracking by default rather than opting out of it with untracked as an exception would be a better mental model. It makes the tracking model clearer, and if it is something the syntax requires denoting by default then I think the distinction between when to or not to track certain things would become more natural to think about.

    Explicit tracking 👍

  11. Harpush commented on May 29, 2024

    @Harpush

    I added an implementation idea in one of those: #54614 (comment)

  12. garrettld commented on May 29, 2024

    @garrettld
    Contributor

    I have a couple concerns with this proposal. First, I'm not sure it actually solves the problems it claims to solve. For example, let's take an effect that alerts each time the user selects an item:

    effect(() => alert(`Selected item: ${this.selectedItem()}.`))

    Now let's implement a new requirement to include the item description in the alert. We don't want to alert when the user edits the description, so we'll need to use untracked:

    effect(() =>
      alert(`Selected item: ${this.selectedItem()}. Description: ${untracked(this.selectedItemDescription)}.`)
    )

    and with the proposed API:

    effect(this.selectedItem, (selectedItem) =>
      alert(`Selected item: ${selectedItem}. Description: ${this.selectedItemDescription()}.`)
    )

    Neither of these options makes it more clear that the resulting behavior was intentional, and that we haven't accidentally introduced a bug. The maintenance overhead described in this proposal hasn't gone away, it just looks different. With the proposed API you still have to look through the effect callback (and any functions called within) to find signal invocations and determine if they were intentionally untracked.

    The proposed API also creates new problems. Specifically, it requires you to "lift" your dependencies out of the effect, which has consequences for performance and readability. For example, let's imagine you're using an effect to conditionally persist changes:

    current API:

    effect(() => {
      if (this.autoSave()) {
        this.dataService.saveItems(this.items().filter(item => item.dirty());
      }
    })

    proposed API:

    dirtyItems = computed(() => this.items().filter(item => item.dirty());
    effect(this.autoSave, this.dirtyItems, (autoSave, dirtyItems) => {
      if (autoSave) {
        this.dataService.saveItems(dirtyItems);
      }
    })

    These two examples behave the same while autoSave is true -- the effect is re-executed when the items signal changes or any item's dirty signal changes.

    However, with the current API the effect will not re-execute while autoSave is false, skipping some work. With the proposed API, that work (calculating dirtyItems and running the effect callback) will be done anyway. You could consider alternate APIs that allow for dynamic dependency arrays instead and kind of get around this, but that gets messy and difficult to maintain pretty quickly.

    I'd also argue that the proposed API version is less readable. Colocating the this.items().filter(...) expression with where it's used (inside the effect callback) is easier to read than the separate computed signal in my opinion.

    Finally, and I think most importantly, my personal opinion is that you shouldn't be doing this in the first place. effect is better thought of as a tool for synchronizing application state to contexts outside of Angular templates, and not as an event handler for signal value changes. Data-loading with a hand-written effect is, as you pointed out, quite annoying and error-prone. You're much better off triggering data-loading some other way (toObservable, Angular Query, Router resolvers, imperative calls in event handlers, etc.) instead. The Angular documentation mentions that "effects are rarely needed in application code" (https://angular.dev/guide/signals#use-cases-for-effects) but I wish it was worded more strongly exactly because overuse leads to the problems described in this issue.

    I think the underlying problem here is that it seems like effect should work well for things like triggering data loading. React has the same problem with useEffect (see https://react.dev/learn/you-might-not-need-an-effect). I would love to see more exploration of this problem space, particularly around rxjs interop and other async/data-loading primitives, but I think that making effect dependencies explicit creates more problems than it solves.

  13. e-oz commented on May 29, 2024

    @e-oz

    I'm pretty sure about two things:

    1. Modifying current effect() is too late. v18 is out, developers already use signals, and a lot of them use effect() (because most programmers write imperative code). It would cause a lot of negative feedback;
    2. Angular core should have a version of effect() with explicit deps tracking. Any community-provided function will be met with skepticism - "If Angular devs would think we need it, they would provide it in the core, it means we maybe don't need it". Angular always was a "batteries included" framework. It will be quite difficult to convince users to use a more safe version of effect().

    For readers who don't have a clear picture of what this is all about and what the possible issues are: you can find examples in my article.


    effect is better thought of as a tool for synchronizing application state

    I disagree, @garrettld . effect() is not for modifying the state, it is for side-effects. To update a DOM, to re-render some map... for dirty imperative work. For computing the state we have computed().

  14. 63 remaining items

  15. added 10 commits that reference this issue on May 22, 2026
    00b7828
    c4fc14b
    60cee91
    e71a22d
    b07bf13
    6cc5d01
    29f32ea
    5182787
    2b29c9f
    62aeb53
  16. JeanMeche commented on May 24, 2026

    @JeanMeche
    Member

    Fiddling with some ideas in #68703 and #68908.
    The latter is more versatile and would prevent API bloat.

  17. wall-street-dev commented on May 25, 2026

    @wall-street-dev

    I can't believe that in the Year of the Lord 2026, this thread is alive because some devs still cannot wrap their heads around a simple untracked inside an effect body. Do you need a daily reminder to wipe your arses? My goodness.

  18. mauriziocescon commented on May 25, 2026

    @mauriziocescon

    I’d probably leave everything as it is as well.

    The situation has vastly improved over the last two years, and the need for 'effect' has drastically decreased. The minor annoyance of using ⁠untracked⁠ is actually a good thing in a way: it reminds you, 'Maybe there’s a better approach than an effect?'

    For everything else, there are custom wrappers (and technically speaking, you can use ⁠untracked⁠ inside a ⁠computed⁠ as well 😅)."

    ☮️

  19. imaksp commented on May 25, 2026

    @imaksp
    Contributor

    Fiddling with some ideas in #68703 and #68908. The latter is more versatile and would prevent API bloat.

    this looks like a helper which uses untracked, IMO new primitive like Vue's watch (https://vuejs.org/guide/essentials/watchers#eager-watchers) would be more helpful. (for example with signal forms)

  20. JanTheMan1010 commented on Jun 3, 2026

    @JanTheMan1010

    One thing at this worth to callout is what solid does with its on until.

    For angular it could be as following:

    function explicit<TData, TResult>(
    pure: () => TData,
    impure: (data: TData) => TResult,
    ): () => TResult {
    return () => {
    const data = pure();
    return untracked(() => impure(data));
    };
    }

    const sig1 = signal(10);
    const sig2 = signal('');

    effect(
    explicit(
    () => ({a: sig1(), b: sig2()}),
    ({a, b}) => {
    console.log(Effect run: sig1=${a}, sig2=${b});
    },
    ),
    );

    I love this, since it's aligned with the rxresource-syntax. Im curious why we can't integrate @JeanMeche "explicit-approach" in effect:

    const sig1= signal(10);
    const sig2 = signal('');
    const noTrackSig = signal(1337);
    
    // do it the old way
    effect(()=> {
       const a = sig1();
       const b = sig2();
       untracked((=> {
        console.log(`Effect run: sig1=${a}, sig2=${b}, noTrackSig=${noTrackSig()}`);
       });
    });
    
    // do it with new syntax
     effect(
      () => ({a: sig1(), b: sig2()}),
      ({a, b}) => {
        console.log(`Effect run: sig1=${a}, sig2=${b}, noTrackSig=${noTrackSig()}`);
      },
    );
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: coreIssues related to the framework runtimecore: reactivityWork related to fine-grained reactivity in the core frameworkcross-cutting: signals

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      Sponsor
      SponsoredKunjungi sekarang
      Promo