platform icon indicating copy to clipboard operation
platform copied to clipboard

Add linkedSignalState

Open Harpush opened this issue 1 year ago • 8 comments

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

signals

Information

Currently signal state is a strong utility to handle state even outside signal store. The missing part is allowing that state to be a linked signal.

I propose a new linkedSignalState which will allow deriving the state based on another signal.

This will allow resetting the state on signal change - or any other state transformation based on some other signal change.

Describe any alternatives/workarounds you're currently using

Currently either don't use signal state or use an effect to update the state.

I would be willing to submit a PR to fix this issue

  • [X] Yes
  • [ ] No

Harpush avatar Dec 20 '24 23:12 Harpush

An example:

const a = signal(2);

// The state is based on another signal (linked)
const state = linkedSignalState(() => ({value: a() + 1}));
// or
const state = linkedSignalState({
    source: a,
    computation: (source) => ({value: source + 1})
});

patchState(state, {value: 5}); // Patch state like any other state
console.log(state.value()); // Deep signal

Harpush avatar Jan 10 '25 13:01 Harpush

Thank you, @Harpush. Rest assured, we are fully aware of the need for this and are actively discussing various strategies to address it. There are also multiple scenarios where linkedSignal could come into play, and we’re taking those into consideration.

We’re not rushing this—our goal is to ensure we get it right the first time. 😊

rainerhahnekamp avatar Jan 11 '25 22:01 rainerhahnekamp

An alternative to keep the store in sync based on an component signal is to use an rxMethod

export interface MyState {
  prop1: Prop1 | null;
  prop2: Prop2 | null;
}

export const MySignalStore = signalStore(
  { providedIn: 'root' },

  withState<MyState>({
    prop1: null,
    prop2: null
  }),

  withMethods(store => ({
    syncProp1: rxMethod<Prop1 | null>(
      pipe(tap(prop1 => patchState(store, { prop1 })))
    )
  }))
);

then in the component you can pass the signal itself to the store method to keep in in sync based every time the prop1 component signal changes.

  prop1 = computed(() => {
    /// return prop1 value
  });
  _ = this.#mySignalStore.syncProp1(this.prop1);

urielzen avatar May 12 '25 02:05 urielzen

Hello, withLinkedState is now available - but still no low level linkedSignalState. Is it something you want to support?

Harpush avatar Oct 23 '25 06:10 Harpush

Oh, didn't see that this issue is still open. @Harpush, I don't really see the benefit of providing a linkedSignalState. We can wait for the rest of the team says and then proceed or close this issue.

@markostanimirovic @timdeschryver

rainerhahnekamp avatar Oct 23 '25 10:10 rainerhahnekamp

@rainerhahnekamp I can give an example when it can be beneficial. Let's assume we have a grid state with a details grid state too. Whenever the parent grid selected row changes the details grid needs to get reset entirely. This is a minimal example with signals services:

interface GridState {
  filters: any[];
  sorts: any[];
  selectedRowId: string | undefined;
}

@Injectable({ providedIn: 'root' })
export class MainGridStoreService {
  private state = signal<GridState>({
    filters: [],
    sorts: [],
    selectedRowId: undefined,
  });

  filters = computed(() => this.state().filters);
  sorts = computed(() => this.state().sorts);
  selectedRowId = computed(() => this.state().selectedRowId);
}

@Injectable({ providedIn: 'root' })
export class DetailsGridStoreService {
  private mainGrid = inject(MainGridStoreService);

  private state = linkedSignal({
    source: this.mainGrid.selectedRowId,
    computation: (): GridState => ({
      filters: [],
      sorts: [],
      selectedRowId: undefined,
    }),
  });

  filters = computed(() => this.state().filters);
  sorts = computed(() => this.state().sorts);
  selectedRowId = computed(() => this.state().selectedRowId);
}

With ngrx signalState and a hypothetical linkedSignalState:

@Injectable({ providedIn: 'root' })
export class MainGridStoreService {
  private state = signalState<GridState>({
    filters: [],
    sorts: [],
    selectedRowId: undefined,
  });

  filters = this.state.filters;
  sorts = this.state.sorts;
  selectedRowId = this.state.selectedRowId;
}

@Injectable({ providedIn: 'root' })
export class DetailsGridStoreService {
  private mainGrid = inject(MainGridStoreService);

  private state = linkedSignalState({
    source: this.mainGrid.selectedRowId,
    computation: (): GridState => ({
      filters: [],
      sorts: [],
      selectedRowId: undefined,
    }),
  });

  filters = this.state.filters;
  sorts = this.state.sorts;
  selectedRowId = this.state.selectedRowId;
}

Harpush avatar Oct 24 '25 13:10 Harpush

Another option is extending signalState to accept a writeable signal. So:

signalState({a: 2});
signalState(signal({a: 2}));
signalState(linkedSignal(() => ({a: 2})));

Which allows having linked state but also doesn't need to copy the linkedSignal declaration.

Harpush avatar Oct 24 '25 23:10 Harpush

Yes, I was also thinking about the option of providing a "user-defined" signal for the signalState. That would also allow other signal types than linkedSignal. We just have to make sure we aren't running into that again: #4863

rainerhahnekamp avatar Oct 25 '25 09:10 rainerhahnekamp