Skip to content

Commit

Permalink
feat: update store
Browse files Browse the repository at this point in the history
  • Loading branch information
skarab42 committed Aug 7, 2022
1 parent c27773c commit 39249a7
Show file tree
Hide file tree
Showing 2 changed files with 135 additions and 6 deletions.
74 changes: 68 additions & 6 deletions src/index.ts
@@ -1,4 +1,7 @@
import { type Immutable, produce } from 'immer';
import { type Immutable, produce, Draft } from 'immer';
import type { Nothing } from 'immer/dist/internal.js';

// ---

export interface Options {
/** Should freeze the initial state outside of `createStore`? (default: true) */
Expand All @@ -11,19 +14,53 @@ export const defaultOptions: Required<Options> = {

export type DefaultOptions = typeof defaultOptions;

// ---

export type InitialState<TState, TOptions extends Options> = true extends TOptions['freezeInitialState']
? Immutable<TState>
: TState;

export type CurrentState<TState, TOptions extends Options> = Immutable<InitialState<TState, TOptions>>;
export type CurrentState<TState> = Immutable<TState>;

// ---

export type Subscriber<TState> = (state: Immutable<TState>) => void;

export interface Subscription {
on: () => Subscription;
off: () => boolean;
}

// ---

export type DraftState<TState> = Draft<Immutable<TState>>;

export type RecipeReturn<TState> =
| (DraftState<TState> extends undefined ? Nothing : never)
| DraftState<TState>
| undefined
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
| void;

export type Recipe<TState> = (draft: DraftState<TState>) => RecipeReturn<TState>;

// ---

export interface Store<TState, TOptions extends Options> {
/** Return the initial state (maybe immutable). */
initial(): InitialState<TState, TOptions>;
/** Return the current state (immutable). */
current(): CurrentState<TState, TOptions>;
current(): CurrentState<TState>;
/** Subscribe to store update event. */
subscribe(subscriber: Subscriber<TState>): Subscription;
/** Unsubscribe from store update event. */
unsubscribe(subscriber: Subscriber<TState>): boolean;
/** Update and return a new immutable state. */
update(recipe: Recipe<TState>): CurrentState<TState>;
}

// ---

/**
* Create and return a new immutable {@link Store} object.
*/
Expand All @@ -37,15 +74,40 @@ export function createStore<TState, TOptions extends Options = DefaultOptions>(
initialState = { ...initialState };
}

const currentState = produce(initialState, (draft) => draft) as CurrentState<TState, TOptions>;
let currentState = produce(initialState, (draft) => draft) as CurrentState<TState>;

function initial(): InitialState<TState, TOptions> {
return initialState;
}

function current(): CurrentState<TState, TOptions> {
function current(): CurrentState<TState> {
return currentState;
}

const subscribers = new Set<Subscriber<TState>>();

function unsubscribe(subscriber: Subscriber<TState>): boolean {
return subscribers.delete(subscriber);
}

function subscribe(subscriber: Subscriber<TState>): Subscription {
subscribers.add(subscriber);

return {
on: () => subscribe(subscriber),
off: () => unsubscribe(subscriber),
};
}

function update(recipe: Recipe<TState>): CurrentState<TState> {
currentState = produce(currentState, recipe);

for (const subscriber of subscribers) {
subscriber(currentState);
}

return currentState;
}

return { initial, current };
return { initial, current, subscribe, unsubscribe, update };
}
67 changes: 67 additions & 0 deletions test/index.test.ts
Expand Up @@ -41,3 +41,70 @@ it('should create an immutable store without freezing the initial store', () =>
expect(store.current()).toStrictEqual({ life: 42 });
expectType(store.current()).identicalTo<{ readonly life: number }>();
});

it('should update the store', () => {
const state = { counter: 0 };
const store = createStore(state);

expect(store.current()).toStrictEqual(state);

const increment = (): { readonly counter: number } =>
store.update((state) => {
state.counter++;
});

expect(increment().counter).toBe(1);
expect(increment().counter).toBe(2);
expect(increment().counter).toBe(3);
});

it('should subscribe/unsubscribe from store update', () => {
const state = { counter: 0 };
const store = createStore(state);

expect(store.current()).toStrictEqual(state);

let counter = 0;

const subscription = store.subscribe((state) => {
counter = state.counter;
});

const increment = (): { readonly counter: number } =>
store.update((state) => {
state.counter++;
});

expect(increment().counter).toBe(1);
expect(counter).toBe(1);

subscription.off();

expect(increment().counter).toBe(2);
expect(increment().counter).toBe(3);
expect(increment().counter).toBe(4);
expect(increment().counter).toBe(5);

expect(counter).toBe(1);

subscription.on();

expect(increment().counter).toBe(6);
expect(increment().counter).toBe(7);

expect(counter).toBe(7);

subscription.off();

expect(increment().counter).toBe(8);
expect(increment().counter).toBe(9);

expect(counter).toBe(7);

subscription.on();

expect(increment().counter).toBe(10);
expect(increment().counter).toBe(11);

expect(counter).toBe(11);
});

0 comments on commit 39249a7

Please sign in to comment.