Hi folks,
I'm presenting signalStore
as a minimal Signal Store to ease the pain of managing state with Signal.
Target
I know that most folks would probably use some State Management libraries: ngrx
, rx-angular
, state-adapt
etc... with Angular. However, signalStore()
would make sense for folks who build Angular libraries where we do not want to depend on a full-fledged state management library.
API
type SignalStore<State extends object> = {
select<
Key1 extends keyof State,
Key2 extends keyof State[Key1],
Key3 extends keyof State[Key1][Key2],
Key4 extends keyof State[Key1][Key2][Key3],
>(
key1: Key1,
key2: Key2,
key3: Key3,
key4: Key4,
options?: CreateComputedOptions<State[Key1][Key2][Key3][Key4]>,
): Signal<State[Key1][Key2][Key3][Key4]>;
select<
Key1 extends keyof State,
Key2 extends keyof State[Key1],
Key3 extends keyof State[Key1][Key2],
>(
key1: Key1,
key2: Key2,
key3: Key3,
options?: CreateComputedOptions<State[Key1][Key2][Key3]>,
): Signal<State[Key1][Key2][Key3]>;
select<Key1 extends keyof State, Key2 extends keyof State[Key1]>(
key1: Key1,
key2: Key2,
options?: CreateComputedOptions<State[Key1][Key2]>,
): Signal<State[Key1][Key2]>;
select<Key extends keyof State>(
key: Key,
options?: CreateComputedOptions<State[Key]>,
): Signal<State[Key]>;
select(options?: CreateComputedOptions<State>): Signal<State>;
get<
Key1 extends keyof State,
Key2 extends keyof State[Key1],
Key3 extends keyof State[Key1][Key2],
Key4 extends keyof State[Key1][Key2][Key3],
>(
key1: Key1,
key2: Key2,
key3: Key3,
key4: Key4,
): State[Key1][Key2][Key3][Key4];
get<
Key1 extends keyof State,
Key2 extends keyof State[Key1],
Key3 extends keyof State[Key1][Key2],
>(
key1: Key1,
key2: Key2,
key3: Key3,
): State[Key1][Key2][Key3];
get<Key1 extends keyof State, Key2 extends keyof State[Key1]>(
key1: Key1,
key2: Key2,
): State[Key1][Key2];
get<Key extends keyof State>(key: Key): State[Key];
get(): State;
set(state: Partial<State> | ((previous: State) => Partial<State>)): void;
patch(state: Partial<State>): void;
state: Signal<State>;
};
select()
accepts keys and nested keys (up to 4 levels). select()
caches and returns the computed Signal
for the keys.
get()
behaves similarly to select()
but it returns the value imperatively instead of Signal
set()
behaves similarly to WriteableSignal#update
but the update is wrapped with untracked()
so it is safe to use inside of effect()
and other places.
patch()
is a little unique. It behaves like set()
, except the previous
state is applied AFTER the incoming state. This ensures incoming undefined
data doesn't override the previous data.
state
is similar to select()
without arguments.
- internal
_snapshot
: this is a non-configurable, non-enumerable getter on the store
object. Useful for debugging current state at the time the getter is read.
signalStore()
accepts an initialState
either as a Partial<State>
or (storeApi: Pick<SignalStore<State>, 'get' | 'set' | 'patch'>) => Partial<State>
- the function version is particularly helpful when your initial state needs some computation / logic you want to encapsulate in the
signalStore()
function.
https://gist.github.com/nartc/b34a6000d2f2e2c552842d7281ca356e
untracked
or not
signalStore
current implementation wraps update calls in untracked
. This is because, in Angular Three's Renderer, things invoke Signal updates during Render, which causes issues with "Update Signal during render". I personally would like to keep the behavior, but we can definitely make it configurable via signalStore()
signature.
Usage
const store = signalStore({
foo: 'default foo',
bar: { baz: 'nested', quz: 123 }
});
// select lazily creates Signal and caches the created computed
const foo = store.select('foo'); // Signal<string>
// select nested property
const baz = store.select('bar', 'baz'); // Signal<string>
// select also accepts CreateComputedOptions
const bar = store.select('bar', { equal: Object.is }); // Signal<{ baz: string, quz: number }>
// read value imperatively at this point
const fooValue = store.get('foo'); // string;
// same nested property syntax
const bazValue = store.get('bar', 'baz');
// state is the readonly Signal
const state = store.state; // Signal<{ foo: string, bar: { baz: string, quz: number } }>
// want to control the computed Signal of the whole store, use select()
const customState = store.select({ equal: equalityFn });
// update state
store.set({ foo: 'updated foo' }); // accepts Partial state
store.set(prev => ({ bar: { ...prev.bar, quz: prev.bar.quz + 1 } }); // accepts update fn that can return Partial state
// (rare usage, Angular Three uses this API) patch state
store.patch({ foo: 'updated foo' }); // state.get('foo') === 'updated foo'
store.patch({ foo: undefined }); // state.get('foo') === 'updated foo'; using patch WILL NOT update the state if the value is "undefined". This API helps with cases where some Inputs have default values and those default values should NOT be overridden by "undefined" values
store.set({ foo: undefined }); // state.get('foo') === undefined; using set WILL ALWAYS update the state
// (rare usage, Angular Three uses this API) functional store creation
signalStore<Counter>(({ set, get, select }) => {
/* some internal custom logic to the store creation phase */
const count = select('count'); // Signal<number>
return {
count: 1,
incrementCount: 0,
decrementCount: 0,
increment: () => set(prev => ({ count: prev.count + 1, incrementCount: prev.incrementCount + 1 })),
decrement: () => set(prev => ({ count: prev.count - 1, decrementCount: prev.decrementCount + 1 })),
double: computed(() => count() * 2)
}
})
Check out one example from Angular Three