Skip to main content

withFeatureFactory()

The withFeatureFactory() function allows passing properties, methods, or signals from a SignalStore to a feature. It is an advanced feature, primarily targeted for library authors for SignalStore features.

Its usage is very simple. It is a function which gets the current store:

function withSum(a: Signal<number>, b: Signal<number>) {
return signalStoreFeature(
withComputed(() => ({
sum: computed(() => a() + b()),
}))
);
}

signalStore(
withState({ a: 1, b: 2 }),
withFeatureFactory((store) => withSum(store.a, store.b))
);

Use Case 1: Mismatching Input Constraints

signalStoreFeature can define input constraints that must be fulfilled by the SignalStore calling the feature. For example, a method load needs to be present to fetch data. The default implementation would be:

type Entity = {
id: number;
name: string;
};

function withEntityLoader() {
return signalStoreFeature(
type<{
methods: {
load: (id: number) => Promise<Entity>;
};
}>(),
withState({
entity: undefined as Entity | undefined,
}),
withMethods((store) => ({
async setEntityId(id: number) {
const entity = await store.load(id);
patchState(store, { entity });
},
}))
);
}

The usage of withEntityLoader would be:

signalStore(
withMethods((store) => ({
load(id: number): Promise<Entity> {
// some dummy implementation
return Promise.resolve({ id, name: 'John' });
},
})),
withEntityLoader()
);

A common issue with generic features is that the input constraints are not fulfilled exactly. If the existing load method would return an Observable<Entity>, we would have to rename that one and come up with a load returning Promise<Entitiy>. Renaming an existing method might not always be an option. Beyond that, what if two different features require a load method with different return types?

Another aspect is that we probably want to encapsulate the load method since it is an internal one. The current options don't allow that, unless the withEntityLoader explicitly defines a _load method.

For example:

signalStore(
withMethods((store) => ({
load(id: number): Observable<Entity> {
return of({ id, name: 'John' });
},
})),
withEntityLoader()
);

withFeatureFactory solves those issues by mapping the existing method to whatever withEntityLoader requires. withEntityLoader needs to move the load method dependency to an argument of the function:

function withEntityLoader(load: (id: number) => Promise<Entity>) {
return signalStoreFeature(
withState({
entity: undefined as Entity | undefined,
}),
withMethods((store) => ({
async setEntityId(id: number) {
const entity = await load(id);
patchState(store, { entity });
},
}))
);
}

withFeatureFactory can now map the existing load method to the required one.

const store = signalStore(
withMethods((store) => ({
load(id: number): Observable<Entity> {
// some dummy implementation
return of({ id, name: 'John' });
},
})),
withFeatureFactory((store) => withEntityLoader((id) => firstValueFrom(store.load(id))))
);

Use Case 2: Generic features with Input Constraints

Another potential issue with advanced features in a SignalStore is that multiple
features with input constraints cannot use generic types.

For example, withEntityLoader is a generic feature that allows the caller to
define the entity type. Alongside withEntityLoader, there's another feature,
withOptionalState, which has input constraints as well.

Due to certain TypeScript limitations,
the following code will not compile:

function withEntityLoader<T>() {
return signalStoreFeature(
type<{
methods: {
load: (id: number) => Promise<T>;
};
}>(),
withState({
entity: undefined as T | undefined,
}),
withMethods((store) => ({
async setEntityId(id: number) {
const entity = await store.load(id);
patchState(store, { entity });
},
}))
);
}

function withOptionalState<T>() {
return signalStoreFeature(
type<{ methods: { foo: () => string } }>(),
withState({
state: undefined as T | undefined,
})
);
}

signalStore(
withMethods((store) => ({
foo: () => 'bar',
load(id: number): Promise<Entity> {
// some dummy implementation
return Promise.resolve({ id, name: 'John' });
},
})),
withOptionalState<Entity>(),
withEntityLoader<Entity>()
);

Again, withFeatureFactory can solve this issue by replacing the input constraint with a function parameter:

function withEntityLoader<T>(loader: (id: number) => Promise<T>) {
return signalStoreFeature(
withState({
entity: undefined as T | undefined,
}),
withMethods((store) => ({
async setEntityId(id: number) {
const entity = await loader(id);
patchState(store, { entity });
},
}))
);
}

function withOptionalState<T>(foo: () => string) {
return signalStoreFeature(
withState({
state: undefined as T | undefined,
})
);
}

signalStore(
withMethods((store) => ({
foo: () => 'bar',
load(id: number): Promise<Entity> {
// some dummy implementation
return Promise.resolve({ id, name: 'John' });
},
})),
withFeatureFactory((store) => withOptionalState<Entity>(store.foo.bind(store))),
withFeatureFactory((store) => withEntityLoader<Entity>(store.load.bind(store)))
);