Skip to content

Conversation

@theVedanta
Copy link

🎯 Changes

βœ… Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested this code locally with pnpm run test:pr.

πŸš€ Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

@changeset-bot
Copy link

changeset-bot bot commented Jan 8, 2026

⚠️ No Changeset found

Latest commit: ef8f4c7

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@riccardoperra
Copy link

riccardoperra commented Jan 9, 2026

@theVedanta Thanks for your PR!

I just did a first review and I've shared some consideration in some files, but to avoid repeating them 'll summarize them here:

  • Instead of creating a signal, subscribing to store and manually unsubscribe with DestroyRef, we should use injectStore, like all the other adapters did. This mean that we have also to install the dependency @tanstack/angular-store
  • We should avoid functions that return an array since it cannot be destructured in classes. The following examples are not valid and doesn't compile:
    export class SearchComponent {
      searchQuery = signal('')
      [debouncedQuery, setSearchQuery] = createDebouncedSignal('', { wait: 500 })
    }
    
    // Correct version
    export class SearchComponent {
      searchQuery = signal('');
      debouncedQuery = createDebouncedSignal('', { wait: 500 });
      // debouncedQuery() -> get value
      // debounceQuery.set() -> set or update that will call maybeExecute
      // debouncedQuery.debouncer -> access to the original debouncer object
    }
    
    I made an example with the "right" version, but just to summarize it also here, the create*Signal functions must return a Signal augmented with the extra properties. In those cases we will create an interface that extends the Signal type.

Comment on lines 40 to 42
export function useDefaultPacerOptions(): PacerProviderOptions {
return inject(PACER_OPTIONS) ?? DEFAULT_OPTIONS
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export function useDefaultPacerOptions(): PacerProviderOptions {
return inject(PACER_OPTIONS) ?? DEFAULT_OPTIONS
}
export function injectPacerOptions(): PacerProviderOptions {
return inject(PACER_OPTIONS) ?? DEFAULT_OPTIONS
}

As a convention I'd prefer to use inject and remove the default* prefix since with DI you could provide the option in a nested node

Comment on lines 77 to 94
const batcher = new AsyncBatcher<TValue>(fn, mergedOptions)
const stateSignal = signal<Readonly<TSelected>>(
selector(batcher.store.state) as Readonly<TSelected>,
)

// Subscribe to store changes and update signal
const unsubscribe = batcher.store.subscribe((state) => {
const selected = selector(state)
stateSignal.set(selected as Readonly<TSelected>)
})

const destroyRef = inject(DestroyRef, { optional: true })
if (destroyRef) {
destroyRef.onDestroy(() => {
unsubscribe()
batcher.flush()
})
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

like the other framework adapters we could use tanstack angular store.

It should be something like this

import {injectStore} from '@tanstack/angular-store';

// your implementation

const state = injectStore(batcher.store, selector);

@@ -0,0 +1,6 @@
// re-export everything from the core pacer package, BUT ONLY from the async-batcher module

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can remove this comment


const items = computed(() => queuer.state().items as Array<TValue>)

return [items, queuer.addItem.bind(queuer), queuer]

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In angular we can't really use this fn if we use it through classes. A better approach could be returning the items signal with properties

interface AsyncQueuedSignal<TValue> extends Signal<TValue> {
  readonly addItem
  readonly queuer
}

const items = computed(() => queuer.state().items as Array<TVAlue>);

return Object.assign(items, {
  addItem: queuer.addItem.bind(queuer),
  queuer
});

@theVedanta
Copy link
Author

@theVedanta Thanks for your PR!

I just did a first review and I've shared some consideration in some files, but to avoid repeating them 'll summarize them here:

  • Instead of creating a signal, subscribing to store and manually unsubscribe with DestroyRef, we should use injectStore, like all the other adapters did. This mean that we have also to install the dependency @tanstack/angular-store

  • We should avoid functions that return an array since it cannot be destructured in classes. The following examples are not valid and doesn't compile:

    export class SearchComponent {
      searchQuery = signal('')
      [debouncedQuery, setSearchQuery] = createDebouncedSignal('', { wait: 500 })
    }
    
    // Correct version
    export class SearchComponent {
      searchQuery = signal('');
      debouncedQuery = createDebouncedSignal('', { wait: 500 });
      // debouncedQuery() -> get value
      // debounceQuery.set() -> set or update that will call maybeExecute
      // debouncedQuery.debouncer -> access to the original debouncer object
    }

    I made an example with the "right" version, but just to summarize it also here, the create*Signal functions must return a Signal augmented with the extra properties. In those cases we will create an interface that extends the Signal type.

Yo!
Gotcha, thank you for the review; sorry for the late reply I was a little busy with this classes since university started. I will check them out today!

@theVedanta
Copy link
Author

@theVedanta Thanks for your PR!

I just did a first review and I've shared some consideration in some files, but to avoid repeating them 'll summarize them here:

  • Instead of creating a signal, subscribing to store and manually unsubscribe with DestroyRef, we should use injectStore, like all the other adapters did. This mean that we have also to install the dependency @tanstack/angular-store

  • We should avoid functions that return an array since it cannot be destructured in classes. The following examples are not valid and doesn't compile:

    export class SearchComponent {
      searchQuery = signal('')
      [debouncedQuery, setSearchQuery] = createDebouncedSignal('', { wait: 500 })
    }
    
    // Correct version
    export class SearchComponent {
      searchQuery = signal('');
      debouncedQuery = createDebouncedSignal('', { wait: 500 });
      // debouncedQuery() -> get value
      // debounceQuery.set() -> set or update that will call maybeExecute
      // debouncedQuery.debouncer -> access to the original debouncer object
    }

    I made an example with the "right" version, but just to summarize it also here, the create*Signal functions must return a Signal augmented with the extra properties. In those cases we will create an interface that extends the Signal type.

Addressed everything so far! Thank you so much for your review :)
Let me know if there's anything else you see that's messed up.

I'm fixing the examples & working on adding testing next.

@benjavicente
Copy link

benjavicente commented Jan 17, 2026

My 2 cents reviewing from my phone:

  • In the TanStack Query adapter, the inject prefix is used instead of create or use for every function that calls another function with the inject prefix or an Angular API that depends on the injection context, like inject and effect. I would do the same here.

  • I saw some APIs like createDebouncedValue that called a signal directly outside of a function. This might break with input signals (NG0950):

@Component()
class ExampleComponent {
  value = input.required<number>()
  // will throw that input signals are not ready
  debounced = createDebouncedValue(value)
}

You can use linkedSignal to avoid some of those issues. I recommend adding tests with components inputs for each API that has a signal as an argument.

@theVedanta
Copy link
Author

My 2 cents reviewing from my phone:

  • In the TanStack Query adapter, the inject prefix is used instead of create or use for every function that calls another function with the inject prefix or an Angular API that depends on the injection context, like inject and effect. I would do the same here.
  • I saw some APIs like createDebouncedValue that called a signal directly outside of a function. This might break with input signals (NG0950):
@Component()
class ExampleComponent {
  value = input.required<number>()
  // will throw that input signals are not ready
  debounced = createDebouncedValue(value)
}

You can use linkedSignal to avoid some of those issues. I recommend adding tests with components inputs for each API that has a signal as an argument.

  1. That makes sense, I'll make sure to do so; pretty much all of the APIs actually depend on an injection context so I'm assuming all of them will be renamed to inject
  2. I think I understand a little bit, but let me try and mess around with this to get some more clarity

@riccardoperra
Copy link

riccardoperra commented Jan 18, 2026

  • I saw some APIs like createDebouncedValue that called a signal directly outside of a function. This might break with input signals (NG0950):

@theVedanta What @benjavicente is saying is that if someone is using a required input, createDebouncedValue(value) will throws an error since the signal will be accessed immediately in the current implementation

 export function injectDebouncedValue<TValue, TSelected = {}>(
  value: Signal<TValue>,
  initialOptions: DebouncerOptions<Setter<TValue>>,
  selector?: (state: DebouncerState<Setter<TValue>>) => TSelected,
): [Signal<TValue>, AngularDebouncer<Setter<TValue>, TSelected>] {
  const debounced = injectDebouncedSignal(value(), initialOptions, selector)
  //   -------------------------------------Λ†

theoretically an implementation with linkedSignal could allow you to manage everything with the correct timing, but it must be checked, i am not sure. In tanstack table required inputs are supported with proxies, and tantack query implementation changed many times, but they are still using proxies / computeds at the top

In case, I would probably use both TValue and a fn<TValue> signature in order to support signal as an argument. or if there are issues with the types, i'll stick only to the callback signature to be consistent with angular primitives

export function injectDebouncedSignal<TValue, TSelected = {}>(
- value: TValue,
+ value: TValue | (() => TValue),
  initialOptions: DebouncerOptions<Setter<TValue>>,
  selector?: (state: DebouncerState<Setter<TValue>>) => TSelected,
): DebouncedSignal<TValue, TSelected> {
-  // const debouncedValue = signal<TValue>(value)
+  const debouncedValue = linkedSignal(() => typeof value === 'function' ? value() : value)

Also we should probably not return an array within those function but just an object. Destructuring is a little weird in angular classes, and not consistent with all other signal impls

@riccardoperra
Copy link

riccardoperra commented Jan 18, 2026

@theVedanta @benjavicente

I’d like to add a note, though. These utilities are effectively things like inject*Signal, etc., so it might actually be correct that internally they only ever create plain signals without derivation. If we were to manage everything with a linkedSignal, that would instead create derived signals which might not follow the same flow.

For example, in the case of the debouncer: if the dependent input changes, the value would probably be updated immediately, without taking the debounce into account. At this point, I think this kind of behavior should be handled externally or handled with a specifici implementation via the inject*Value

  • injectDebouncer: responsible to create the pacer core debouncer with initial options handling reactivity (state with @tanstack/store)
  • injectDebouncerSignal: responsible to create a signal with an initial value. the set will call in this case the debouncer.maybeExecute setter, which will call the signal setter with debouncing
  • injectDebouncerValue: support derivations. **Currently this is reusing the **`` logic, but i am not sure this is correct if we need to handle also required inputs.. The issue here are that for async ops, we cannot really create a signal with an initial value and let it update every time the derivated one changes (linkedSignal)

Another impl that may work

import {linkedSignal, untracked} from '@angular/core';


function debouncedValue<T>(
  value: () => T,
  initialOptions?,
  selector?
) {
  const staleValue = {};
  const debouncedSignal = createDebouncedSignal(staleValue, initialOptions, selector);

  const value = computed(() => {
    const val = debouncedSignal.value(); // <--- debouncedSignal() or debouncedSignal.value()? the utility that return a signal probably will just have the accessor
    const initialValue = value();
    if (val === staleValue) return initialValue;
    return val;
  })

  return ...
}

@riccardoperra
Copy link

I’m also not entirely sure that we should use inject* everywhere, especially for utilities whose responsibility is simply to create and return a signal with setter. I understand that in the TanStack ecosystem (Query, Form, Table, etc.) the inject pattern is used, but in this case the function is just creating a signal and returning it.

If we compare this with Angular, they use signal, linkedSignal, and resource, not injectResource, injectLinkedSignal, and so on.

I remember that some time ago this was being discussed. What do you think about it? @crutchcorn @arnoud-dv (sorry for the tag, but you might be able to help.)

@theVedanta
Copy link
Author

I’m also not entirely sure that we should use inject* everywhere, especially for utilities whose responsibility is simply to create and return a signal with setter. I understand that in the TanStack ecosystem (Query, Form, Table, etc.) the inject pattern is used, but in this case the function is just creating a signal and returning it.

If we compare this with Angular, they use signal, linkedSignal, and resource, not injectResource, injectLinkedSignal, and so on.

I remember that some time ago this was being discussed. What do you think about it? @crutchcorn @arnoud-dv (sorry for the tag, but you might be able to help.)

Oh yeah, for sure! I just ran a simple replace query for now to rename all create* modules to inject* since almost all of them wrap the injectStore from angular-store in some way or the other and run in an injection context.

I'll take a dive and find the utils that might need to be more appropriately named. If you've seen some already, feel free to drop them here for a starting point for me!

Again, I really appreciate all your help :)

@crutchcorn
Copy link
Member

I'd rather remain consistent with the rest of our ecosystem and call it inject. A small bonus is that if we ever need to refactor the internals we'll be covered.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants