ngrx / platform

Reactive State for Angular
https://ngrx.io
Other
7.99k stars 1.96k forks source link

Expose more models to the public API: SignalStoreFeatureResult and InputSignalStore<Input> #4272

Closed GuillaumeNury closed 1 month ago

GuillaumeNury commented 5 months ago

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

signals

Information

In order to add more complexe features, it would be great to expose more models to the public API.

Here is an example of what I try to achieve

export const ExampleStore = signalStore(
  withState<ExampleState>({ field: 12 }),
  withMethods(
    (store) => ({
      log(text: string): void {
        console.log(text)
      },
    }),
  ),
  withTabVisibility({
    onTabVisible: (store) => store.log('tab is visible: ' + store.field())
  }),
});

It is possible but I used many unexposed models:

import { SignalStoreFeature, StateSignal } from '@ngrx/signals';
import type {
  EmptyFeatureResult,
  SignalStoreFeatureResult,
  SignalStoreSlices,
} from '@ngrx/signals/src/signal-store-models';

type Prettify<T> = {
  [K in keyof T]: T[K];
} & {};

type TabVisibilityTrigger<Input extends SignalStoreFeatureResult> = (
  store: Prettify<
    SignalStoreSlices<Input['state']> &
      Input['signals'] &
      Input['methods'] &
      StateSignal<Prettify<Input['state']>>
  >,
) => void;

export function withTabVisibility<
  Input extends SignalStoreFeatureResult,
>(options: {
  onTabVisible?: TabVisibilityTrigger<Input>;
  onTabHidden?: TabVisibilityTrigger<Input>;
}): SignalStoreFeature<Input, EmptyFeatureResult> {
  return (store) => {
    const inputStore = {
      ...store, // Cannot use [STATE_SIGNAL] because it's not exposed
      ...store.slices,
      ...store.signals,
      ...store.methods,
    };
    return {
      ...store,
      hooks: {
        onInit: () => {
          store.hooks.onInit?.();
          console.log('onInit');
          document.addEventListener('visibilitychange', () =>
            document.hidden
              ? options.onTabHidden?.(inputStore)
              : options.onTabVisible?.(inputStore),
          );
        },
      },
    };
  };
}

What would be marvelous would be to:

I could have written:

import { SignalStoreFeature, StateSignal, createFeatureInputStore, SignalStoreFeatureResult, EmptyFeatureResult, SignalStoreFeatureInputStore } from '@ngrx/signals';

type TabVisibilityTrigger<Input extends SignalStoreFeatureResult> = (
  store: SignalStoreFeatureInputStore<Input>,
) => void;

export function withTabVisibility<
  Input extends SignalStoreFeatureResult,
>(options: {
  onTabVisible?: TabVisibilityTrigger<Input>;
  onTabHidden?: TabVisibilityTrigger<Input>;
}): SignalStoreFeature<Input, EmptyFeatureResult> {
  return (store) => {
    const inputStore = createFeatureInputStore(store);
    return {
      ...store,
      hooks: {
        onInit: () => {
          store.hooks.onInit?.();
          console.log('onInit');
          document.addEventListener('visibilitychange', () =>
            document.hidden
              ? options.onTabHidden?.(inputStore)
              : options.onTabVisible?.(inputStore),
          );
        },
      },
    };
  };
}

Thanks for this awesome lib ❤️

Describe any alternatives/workarounds you're currently using

It is possible to add hooks with:

const withTabVisiblity = signalStoreFeature(
    {
      methods: type<Partial<{ onTabVisible(): void; onTabHidden(): void }>>(),
    }
    // ...
)

Then:

export const ExampleStore = signalStore(
  withState<ExampleState>({}),
  withMethods(
    (store) => ({
      onTabVisible(): void {
        console.log('tab is visible')
      },
    }),
  ),
  withTabVisibility(),

The onTabVisible method seems completely decoupled from the withTabVisibility feature.

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

markostanimirovic commented 1 month ago

Hi @GuillaumeNury,

I agree that we need more types in the public API to support advanced use cases such as this one. However, it's important to mention that this is not the right way of building custom features. We should not reimplement the logic of base features, but use them instead because the internal logic can change in the meantime.

This is how withTabVisibility feature should look like:

type TabVisibilityMethod<Input extends SignalStoreFeatureResult> = (
  store: Prettify<
    StateSignals<Input['state']> &
      Input['computed'] &
      Input['methods'] &
      StateSource<Input['state']>
  >
) => void;

export function withTabVisibility<
  Input extends SignalStoreFeatureResult
>(methods: {
  onTabVisible?: TabVisibilityMethod<Input>;
  onTabHidden?: TabVisibilityMethod<Input>;
}): SignalStoreFeature<Input, { state: {}; computed: {}; methods: {} }> {
  return signalStoreFeature(
    type<Input>(),
    withHooks((store) => {
      const visibilityChangeFn = () => {
        document.hidden
          ? methods.onTabHidden?.(store)
          : methods.onTabVisible?.(store);
      };

      return {
        onInit() {
          document.addEventListener('visibilitychange', visibilityChangeFn);
        },
        onDestroy() {
          document.removeEventListener('visibilitychange', visibilityChangeFn);
        },
      };
    })
  );
}

// Usage:

const CounterStore = signalStore(
  withState({ count: 0 }),
  withMethods((store) => ({
    increment(): void {
      patchState(store, { count: store.count() + 1 });
    },
    decrement(): void {
      patchState(store, { count: store.count() - 1 });
    },
  })),
  withTabVisibility({
    onTabVisible: ({ increment }) => increment(),
    onTabHidden: ({ decrement }) => decrement(),
  })
);

The following models are missing from the public API to implement features such as this one:

They will be added to the public API.

GuillaumeNury commented 1 month ago

@markostanimirovic thanks for your reply!

I cannot find what StateSource is.

Would it be better to expose SignalStoreFeatureResult, StateSignals, Prettify and StateSource or SignalStoreFeatureResult and a new type:

// This type avoid exposing "utils" like Prettify
export type SignalStoreFeatureInputStore<Input extends SignalStoreFeatureResult> = Prettify<
    StateSignals<Input['state']> &
    Input['computed'] &
    Input['methods'] &
    StateSource<Input['state']>
>

The previous implementation would become:

type TabVisibilityMethod<Input extends SignalStoreFeatureResult> = (
  store: SignalStoreFeatureInputStore<Input>
) => void;

export function withTabVisibility<
  Input extends SignalStoreFeatureResult
>(methods: {
  onTabVisible?: TabVisibilityMethod<Input>;
  onTabHidden?: TabVisibilityMethod<Input>;
}): SignalStoreFeature<Input, { state: {}; computed: {}; methods: {} }> {
  return signalStoreFeature(
    type<Input>(),
    withHooks((store) => {
      const visibilityChangeFn = () => {
        document.hidden
          ? methods.onTabHidden?.(store)
          : methods.onTabVisible?.(store);
      };

      return {
        onInit() {
          document.addEventListener('visibilitychange', visibilityChangeFn);
        },
        onDestroy() {
          document.removeEventListener('visibilitychange', visibilityChangeFn);
        },
      };
    })
  );
}

// Usage:

const CounterStore = signalStore(
  withState({ count: 0 }),
  withMethods((store) => ({
    increment(): void {
      patchState(store, { count: store.count() + 1 });
    },
    decrement(): void {
      patchState(store, { count: store.count() - 1 });
    },
  })),
  withTabVisibility({
    onTabVisible: ({ increment }) => increment(),
    onTabHidden: ({ decrement }) => decrement(),
  })
);

Anyway, thanks for the type<Input>() trick!

markostanimirovic commented 1 month ago

I cannot find what StateSource is.

StateSignal will be renamed to StateSource in v18. https://github.com/ngrx/platform/pull/4424

Would it be better to expose SignalStoreFeatureResult, StateSignals, Prettify and StateSource or SignalStoreFeatureResult and a new type:

It will be useful for features that need access to all store members. However, there may be a case where you want to restrict access and expose e.g. only state and computed signals:

type MyCustomFeatureMethod<Input extends SignalStoreFeatureResult> = (
  store: Prettify<StateSignals<Input['state']> & Input['computed']>
) => void;

Therefore, types are separated for better flexibility.

GuillaumeNury commented 1 month ago

@markostanimirovic Ok, makes sense!

GuillaumeNury commented 1 month ago

@markostanimirovic EmptyFeatureResult would be useful too