Skip to content

feat(core): add custom set option to linkedSignal#68708

Open
alxhub wants to merge 1 commit into
angular:mainfrom
alxhub:ls-set
Open

feat(core): add custom set option to linkedSignal#68708
alxhub wants to merge 1 commit into
angular:mainfrom
alxhub:ls-set

Conversation

@alxhub
Copy link
Copy Markdown
Member

@alxhub alxhub commented May 13, 2026

Introduce a custom set option in linkedSignal options to allow overriding and customizing the default write-back behavior of writable signals. This lets developers route updates back to the source of truth (e.g., converting Fahrenheit back to Celsius) or perform other side effects like updating properties inside a parent signal.

Additionally, the custom callback receives the standard signal setter as its second parameter (rawSet) to allow direct internal mutation if desired.

TAG=agy
CONV=addbb5c4-4233-49e8-b844-6f732d7d5c72

PR Checklist

Please check if your PR fulfills the following requirements:

PR Type

What kind of change does this PR introduce?

  • Bugfix
  • Feature
  • Code style update (formatting, local variables)
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • CI related changes
  • Documentation content changes
  • angular.dev application / infrastructure changes
  • Other... Please describe:

What is the current behavior?

Issue Number: N/A

What is the new behavior?

Does this PR introduce a breaking change?

  • Yes
  • No

Other information

@alxhub alxhub marked this pull request as ready for review May 13, 2026 00:13
@angular-robot angular-robot Bot added detected: feature PR contains a feature commit area: core Issues related to the framework runtime labels May 13, 2026
@ngbot ngbot Bot added this to the Backlog milestone May 13, 2026
@JeanMeche JeanMeche added target: minor This PR is targeted for the next minor release adev: preview labels May 13, 2026
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 13, 2026

Deployed adev-preview for f82284b to: https://ng-dev-previews-fw--pr-angular-angular-68708-adev-prev-7esgb0da.web.app

Note: As new commits are pushed to this pull request, this link is updated after the preview is rebuilt.

Comment thread adev/src/content/guide/signals/linked-signal.md
Copy link
Copy Markdown
Member

@JeanMeche JeanMeche left a comment

Choose a reason for hiding this comment

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

reviewed-for: public-api

@Harpush
Copy link
Copy Markdown

Harpush commented May 13, 2026

Is this the angular solution for deep signal?
The nested property example (which can be wrapped as a helper function) essentially allows for writeable property signal that updates the state object which also can be two way bound

@ronnain
Copy link
Copy Markdown

ronnain commented May 13, 2026

I think this is an anti-pattern.

Why not use an effect in this case?

The pattern being promoted here is essentially expressing a reaction:

  • when the linked signal changes, update X

That’s exactly what effect is meant for.

For more advanced patterns, where the logic needs to be encapsulated, I’d recommend something similar to what I implemented in my craft-ng library.

[Demo](https://stackblitz.com/edit/github-t13xquzq?file=src%2Fapp%2Fexamples%2Fplayground%2Fplayground.ts&initialpath=/playground)

// case 1: declarative states
tempC = state(0, ({ set }) => {
  effect(() => {
    const valF = this.tempF();
    set(((valF - 32) * 5) / 9);
  });

  return {
    set,
  };
});

tempF = state(
  computed(() => (this.tempC() * 9) / 5 + 32),
  ({ set }) => ({ set })
);

// case 2: imperative change
tempCbis = state(0, ({ set }) => ({
  set,
  syncWithTempF: (valF: number) =>
    set(((valF - 32) * 5) / 9),
}));

tempFbis = state(
  computed(() => (this.tempCbis() * 9) / 5 + 32),
  ({ set }) => ({
    set: (valF: number) => {
      set(valF);
      this.tempCbis.syncWithTempF(valF);
    },
  })
);

@JeanMeche
Copy link
Copy Markdown
Member

@ronnain Using an effect would actually be frowned upon here. It even delays the write to the initial signal.

@alxhub
Copy link
Copy Markdown
Member Author

alxhub commented May 13, 2026

Yeah, this is exactly the anti-pattern for effect: using it to synchronize between two signals. It has two problems in this case:

  1. Timing. Effects introduce time into the equation. When tempF is set, there's a period of time before the effect runs where tempC has not yet been updated, breaking the consistency of the signal graph.

  2. Data flow. Effects don't distinguish the direction of the change. When tempC changes, that changes the derived tempF, and the effect would notice that and try to write back to tempC, creating a cycle. In the best case this is a no-op, but when dealing with things like floating point math in the worst case this could become an infinite loop.

The setter override solves both problems. It acknowledges that tempF is a derived signal, and it should be updated by setting its source-of-truth instead. The code example:

tempFbis = state(
  computed(() => (this.tempCbis() * 9) / 5 + 32),
  ({ set }) => ({
    set: (valF: number) => {
      set(valF);
      this.tempCbis.syncWithTempF(valF);
    },
  })

is doing the exact same thing - implementing set for tempF by routing the write to tempC.

@ronnain
Copy link
Copy Markdown

ronnain commented May 14, 2026

I understand your arguments for not wanting to use an effect, although in the vast majority of cases I don't think it would cause issues — or at least, the remaining issues feel more like business logic concerns to me.

And those remaining edge cases can usually be avoided quite easily by customizing the equal option appropriately.

If synchronization really needs to be strict, we could even avoid signals entirely and rely on observables instead.

However, the override-style setter approach I’m proposing introduces an important nuance compared to the current set behavior provided by linkedSignal.

Even though the update itself is imperative, the resulting state remains declarative — and that makes a huge difference.


The current solution feels a bit like a lifecycle callback to me. That makes sense for asynchronous workflows, but feels much less relevant in synchronous scenarios.


I’d also suggest considering a slightly different approach that could unlock much more advanced composition patterns.

Instead of passing an object as the second argument, pass a callback function that receives utilities such as set, update, source, previousSource, previousValue, etc.

And similar to what I did with my state utilities, whenever this second argument is provided, the linked signal itself remains read-only and exposes whatever is returned from the callback.

That would allow highly declarative and composable state definitions.

It would also open the door to many powerful patterns. For example, we could introduce a pipe API to compose multiple callbacks that progressively enrich the signal.

const tempF = linkedSignal(
  () => (tempC() * 9) / 5 + 32,
  ({ set, value }) => ({
    // expose custom setter
    set: (valF: number) => {
      set(valF);
      tempC.set(((valF - 32) * 5) / 9);
    },

    isHot: computed(() => value() > 90),
  }),
);

tempF(); // number
tempF.set(95);
tempF.isHot(); // boolean

Example with composition:

const tempF = linkedSignal(
  () => 0,
  pipe(
    ({ set, value }) => ({
      set: (valF: number) => {
        set(valF);
        tempC.set(((valF - 32) * 5) / 9);
      },

      isHot: computed(() => value() > 90),
    }),

    withIncrement(),
    withLogChange(),
    withSyncLocalStorage(),
  ),
);

You can go pretty far with this kind of approach. If you're interested, you can take a look at what I’m doing with [insertions](https://ng-angular-stack.github.io/craft/insertions/insert-select.html) in my library.


Otherwise, without enabling declarative writable state composition, I don’t really see the value of the current solution as it stands.

Inside a service or component, we can already do something like this:

public readonly tempC = signal(0);

public readonly tempF = computed(...);

public setTempF() {
  // update tempC
}

Or:

source = signal<{type: 'C' | 'F', value: number}>({type: 'C', value: 0});

tempC = computed(...);

tempF = computed(...);

To conclude, it’s already fairly easy to avoid the problems originally raised.
And while the proposed solution does improve things slightly, in practice I don’t think it will end up being used that much.

Introduce a custom `set` option in `linkedSignal` options to allow overriding and customizing the default write-back behavior of writable signals. This lets developers route updates back to the source of truth (e.g., converting Fahrenheit back to Celsius) or perform other side effects like updating properties inside a parent signal.

Additionally, the custom callback receives the standard signal setter as its second parameter (`rawSet`) to allow direct internal mutation if desired.

Fixes angular#59665

TAG=agy
CONV=addbb5c4-4233-49e8-b844-6f732d7d5c72
@mikezks
Copy link
Copy Markdown

mikezks commented May 14, 2026

Great news that you add this API to the framework.

One suggestion regarding the signature:
We now have a simple signature and an extended one.

Simple:

linkedSignal(mySourceSingnal)

Extended:

linkedSignal({
 source: mySourceSignal,
 computation: (value, prev) => value
})

I would love to see the same for the set fn:

linkedSignal(
  mySourceSingnal,
  mySameTypeSetter
)

Extended:

linkedSignal({
 source: mySourceSignal,
 computation: (value, prev) => value,
 set: mySameTypeSetter
})

The need of using the options object in the simple signature to define a set function is kind of bulky.

Example of lean signature

This is also far better readable in case of factory implementations that do make sense in practice:
Definition, Template usage

@mauriziocescon
Copy link
Copy Markdown

mauriziocescon commented May 15, 2026

Related as well #68280

@wartab
Copy link
Copy Markdown
Contributor

wartab commented May 15, 2026

I feel like the name of the property might not be descriptive enough. Was there any consideration for something like onSet or setHook instead? Perhaps I'm missing some subtlety?

Also it's not fully clear from the documentation what happens if you update the source signal with a "wrong" source value (eg. you failed the the backwards conversion)?

@mikezks
Copy link
Copy Markdown

mikezks commented May 15, 2026

First, you do not necessarily update the source.
Second, it is type-safe, so the IDE and compiler will show type errors. During runtime it is like always in JS.

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

Labels

adev: preview area: core Issues related to the framework runtime detected: feature PR contains a feature commit target: minor This PR is targeted for the next minor release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants