Skip to content

Initial state of class-based signalStore cannot be set from subclass #4700

@dboryga

Description

@dboryga

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

signals

Information

It is not possible to create class-based signalStore and set it's initial value from subclass. Our intention is to have common properties and methods in a store class, but also have possibility to assign additional properties from the subclass.

When creating signalStore it is required to set withState, and it is not possible to assign additional properties.

Issue background:
Migrating from component store and we'd like to keep our store api as similar as possible.

Initial approach:

// STORE
export interface PageStoreType {
  commonProp: string;
}

export const initialPageState = {
  commonProp: ''
}

export abstract class PageStore<StateType extends PageStoreType> 
  extends signalStore({protectedState: false}, withState(initialPageState)) {

    constructor(initialState: StateType) {
      super();
      patchState(this, initialState);
    }
}

// COMPONENT
interface TestComponentState extends PageStoreType {
  additionalProp1: string;
  additionalProp2: number
}

export const testComponentInitialState: TestComponentState = {
  ...initialPageState,
  additionalProp1: 'test',
  additionalProp2: 2
}


@Component({ template: '' })
export class TestComponent extends PageStore<ComponentState> {
  
  constructor() {
    super(testComponentInitialState);
  }

  readonly test1 = computed(() => this.commonProp());
  readonly test2 = computed(() => this.additionalProp1());  // Property 'additionalProp1' does not exist on type 'TestComponent'.
}

Code above have 2 issues:

  1. Additional properties are not added via patchState
  2. StateType cannot be inferred (Base class expressions cannot reference class type parameters.)

Describe any alternatives/workarounds you're currently using

For now, we switched back to component store, as we don't have perfect solution yet.

Potential solutions:

1. Using signalState instead of extending signalStore:
As we don't use any features of the store, other than its state, we can create signalState inside our custom store class.

export class Store<StateType extends PageStoreType> {
  public readonly state;

  constructor(initialState: StateType = initialPageState as StateType) {
    this.state = signalState(initialState);
  }

  private get internalStore() {
    return this.state as unknown as SignalState<PageStoreType>;
  }

  public readonly test1 = computed(() => this.internalStore.commonProp());
}


@Component({ template: '' })
export class SolutionOne extends Store<TestComponentState> {
  constructor() {
    super(testComponentInitialState);
  }

  public readonly test2 = computed(() => this.state.commonProp());
  public readonly test3 = computed(() => this.state.additionalProp1());
}

It works fine - we have access to common properties in the store, and additional properties in the component - but requires to use state property (which is inconsistent with signalStore), and we need to create internalStore to correctly assign base type (to use common properties inside the store class).

2. Injection token for initial value:
Possible solution to not assigining additional properties is to inject initial state as a argument of withState

export const INITIAL_STATE = new InjectionToken<PageStoreType>(
  'INITIAL_STATE',
  {
    providedIn: 'root',
    factory: () => initialPageState,
  }
);


export class Store<StateType extends PageStoreType> extends signalStore(
  { protectedState: false },
  withState(() => inject(INITIAL_STATE))
) {
  readonly test1 = computed(() => this.commonProp());
}


@Component({
  template: '',
  providers: [
    {
      provide: INITIAL_STATE,
      useValue: testComponentInitialState,
    },
  ],
})
export class SolutionTwo extends Store<TestComponentState> {
  readonly test2 = computed(() => this.commonProp());
  readonly test3 = computed(() => this.additionalProp1()); // Property 'additionalProp1' does not exist on type 'TestComponent'.
}

Initial value is assigned correctly, but type cannot be correctly inefred, so typescript does not know about additional properties.

3. Factory for signalStore

export const PageStore = <StateType extends PageStoreType>(
  initialState: StateType
) => signalStore({ protectedState: false }, withState(initialState));


@Component({ template: '' })
export class SolutionThree extends PageStore<ComponentState>(componentInitialState) {
  constructor() {
    super();
  }

  readonly test2 = computed(() => this.additionalProp1());
  readonly test3 = computed(() => this.additionalProp1());
}

Works fine, but forces standard api instead of class-based.

All previews available on stackblitz.com

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

  • Yes
  • No

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions