@mpen/react-external-state 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,189 @@
1
+ # @mpen/react-external-state
2
+
3
+ Small external state stores with optional React bindings and localStorage persistence.
4
+
5
+ The base store does not depend on React. Use `@mpen/react-external-state/react` only when you want hooks or context-scoped stores.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ bun add @mpen/react-external-state
11
+ ```
12
+
13
+ ## Store
14
+
15
+ ```ts
16
+ import { createStore } from '@mpen/react-external-state'
17
+
18
+ const counter = createStore({ count: 0 })
19
+
20
+ const unsubscribe = counter.subscribe((state, previousState) => {
21
+ console.log(previousState.count, '->', state.count)
22
+ })
23
+
24
+ counter.setState((state) => ({
25
+ count: state.count + 1,
26
+ }))
27
+
28
+ counter.set({ count: 10 })
29
+
30
+ console.log(counter.get())
31
+
32
+ unsubscribe()
33
+ ```
34
+
35
+ Use `subscribeSelector` when code outside React only cares about part of the state.
36
+
37
+ ```ts
38
+ const unsubscribe = counter.subscribeSelector(
39
+ (state) => state.count,
40
+ (count) => {
41
+ console.log('count changed:', count)
42
+ },
43
+ { fireImmediately: true },
44
+ )
45
+ ```
46
+
47
+ ## React
48
+
49
+ ```tsx
50
+ import { createReactStore } from '@mpen/react-external-state/react'
51
+
52
+ const session = createReactStore({
53
+ userId: null as string | null,
54
+ theme: 'system' as 'light' | 'dark' | 'system',
55
+ })
56
+
57
+ export function ThemeButton() {
58
+ const theme = session.use((state) => state.theme)
59
+
60
+ return (
61
+ <button
62
+ type="button"
63
+ onClick={() => {
64
+ session.setState((state) => ({
65
+ ...state,
66
+ theme: state.theme === 'dark' ? 'light' : 'dark',
67
+ }))
68
+ }}
69
+ >
70
+ {theme}
71
+ </button>
72
+ )
73
+ }
74
+
75
+ session.setState((state) => ({
76
+ ...state,
77
+ userId: 'user_123',
78
+ }))
79
+ ```
80
+
81
+ You can also use an existing store with `useStore`.
82
+
83
+ ```tsx
84
+ import { createStore } from '@mpen/react-external-state'
85
+ import { useStore } from '@mpen/react-external-state/react'
86
+
87
+ const counter = createStore({ count: 0 })
88
+
89
+ export function Counter() {
90
+ const count = useStore(counter, (state) => state.count)
91
+
92
+ return (
93
+ <button
94
+ type="button"
95
+ onClick={() => counter.setState((state) => ({ count: state.count + 1 }))}
96
+ >
97
+ {count}
98
+ </button>
99
+ )
100
+ }
101
+ ```
102
+
103
+ ## localStorage
104
+
105
+ ```ts
106
+ import { createLocalStorageStore } from '@mpen/react-external-state'
107
+
108
+ const settings = createLocalStorageStore('app.settings', {
109
+ theme: 'system' as 'light' | 'dark' | 'system',
110
+ sidebarOpen: true,
111
+ })
112
+
113
+ settings.setState((state) => ({
114
+ ...state,
115
+ sidebarOpen: !state.sidebarOpen,
116
+ }))
117
+ ```
118
+
119
+ By default values are serialized with `JSON.stringify` and restored with `JSON.parse`. Storage failures are ignored unless you pass `onError`.
120
+
121
+ ```ts
122
+ const settings = createLocalStorageStore(
123
+ 'app.settings',
124
+ { theme: 'system' },
125
+ {
126
+ onError(error, operation) {
127
+ console.warn(`Could not ${operation} settings`, error)
128
+ },
129
+ },
130
+ )
131
+ ```
132
+
133
+ ## Context
134
+
135
+ Use `createStoreContext` when state should be scoped to a React subtree instead of global module state.
136
+
137
+ ```tsx
138
+ import { createStoreContext } from '@mpen/react-external-state/react'
139
+
140
+ const DraftContext = createStoreContext({
141
+ title: '',
142
+ body: '',
143
+ })
144
+
145
+ export function DraftEditor() {
146
+ return (
147
+ <DraftContext.Provider initialValue={{ title: 'Untitled', body: '' }}>
148
+ <TitleInput />
149
+ <Preview />
150
+ </DraftContext.Provider>
151
+ )
152
+ }
153
+
154
+ function TitleInput() {
155
+ const title = DraftContext.use((state) => state.title)
156
+ const setDraft = DraftContext.useSetState()
157
+
158
+ return (
159
+ <input
160
+ value={title}
161
+ onChange={(event) => {
162
+ setDraft((state) => ({
163
+ ...state,
164
+ title: event.currentTarget.value,
165
+ }))
166
+ }}
167
+ />
168
+ )
169
+ }
170
+
171
+ function Preview() {
172
+ const draft = DraftContext.use()
173
+
174
+ return <article>{draft.title}</article>
175
+ }
176
+ ```
177
+
178
+ ## API
179
+
180
+ - `createStore(initialValue, options?)`
181
+ - `createLocalStorageStore(key, initialValue, options?)`
182
+ - `Store`
183
+ - `store.get()` / `store.getSnapshot()`
184
+ - `store.set(valueOrUpdater)` / `store.setState(valueOrUpdater)`
185
+ - `store.subscribe(listener, options?)`
186
+ - `store.subscribeSelector(selector, listener, options?)`
187
+ - `useStore(store, selector?, options?)` from `@mpen/react-external-state/react`
188
+ - `createReactStore(initialValue, options?)` from `@mpen/react-external-state/react`
189
+ - `createStoreContext(defaultValue, options?)` from `@mpen/react-external-state/react`
@@ -0,0 +1,13 @@
1
+ import { a as Store, c as StoreSnapshot, d as createStore, f as resolveInitializer, i as StateUpdater, l as SubscribeOptions, n as Initializer, o as StoreListener, p as resolveStateUpdater, r as Selector, s as StoreOptions, t as EqualityFn, u as Unsubscribe } from "./store-B4lfgyM1.js";
2
+
3
+ //#region src/local-storage.d.ts
4
+ type StorageLike = Pick<Storage, 'getItem' | 'setItem'>;
5
+ interface LocalStorageStoreOptions<T> extends StoreOptions<T> {
6
+ storage?: StorageLike | null;
7
+ serialize?: (value: T) => string;
8
+ deserialize?: (value: string) => T;
9
+ onError?: (error: unknown, operation: 'read' | 'write') => void;
10
+ }
11
+ declare function createLocalStorageStore<T>(key: string, initialValue: Initializer<T>, options?: LocalStorageStoreOptions<T>): Store<T>;
12
+ //#endregion
13
+ export { type EqualityFn, type Initializer, type LocalStorageStoreOptions, type Selector, type StateUpdater, type StorageLike, Store, type StoreListener, type StoreOptions, type StoreSnapshot, type SubscribeOptions, type Unsubscribe, createLocalStorageStore, createStore, resolveInitializer, resolveStateUpdater };
package/dist/index.js ADDED
@@ -0,0 +1,30 @@
1
+ import { i as resolveStateUpdater, n as createStore, r as resolveInitializer, t as Store } from "./store-DblPx3Yb.js";
2
+ //#region src/local-storage.ts
3
+ function getDefaultStorage() {
4
+ if (typeof globalThis.localStorage === "undefined") return null;
5
+ return globalThis.localStorage;
6
+ }
7
+ function createLocalStorageStore(key, initialValue, options) {
8
+ const storage = options?.storage === void 0 ? getDefaultStorage() : options.storage;
9
+ const deserialize = options?.deserialize ?? JSON.parse;
10
+ const serialize = options?.serialize ?? JSON.stringify;
11
+ let value = initialValue;
12
+ if (storage !== null) try {
13
+ const storedValue = storage.getItem(key);
14
+ if (storedValue !== null) value = deserialize(storedValue);
15
+ } catch (error) {
16
+ options?.onError?.(error, "read");
17
+ }
18
+ const store = createStore(value, options);
19
+ store.subscribe((nextValue) => {
20
+ if (storage === null) return;
21
+ try {
22
+ storage.setItem(key, serialize(nextValue));
23
+ } catch (error) {
24
+ options?.onError?.(error, "write");
25
+ }
26
+ });
27
+ return store;
28
+ }
29
+ //#endregion
30
+ export { Store, createLocalStorageStore, createStore, resolveInitializer, resolveStateUpdater };
@@ -0,0 +1,39 @@
1
+ import { a as Store, c as StoreSnapshot, i as StateUpdater, n as Initializer, r as Selector, s as StoreOptions, t as EqualityFn } from "./store-B4lfgyM1.js";
2
+ import * as _$react from "react";
3
+ import { ReactNode } from "react";
4
+ import * as _$react_jsx_runtime0 from "react/jsx-runtime";
5
+
6
+ //#region src/react.d.ts
7
+ interface UseStoreOptions<S> {
8
+ isEqual?: EqualityFn<S>;
9
+ }
10
+ interface ReactStore<T> extends Store<T> {
11
+ use(): T;
12
+ use<S>(selector: Selector<T, S>, options?: UseStoreOptions<S>): S;
13
+ }
14
+ interface StoreProviderProps<T> {
15
+ children?: ReactNode;
16
+ initialValue?: StateUpdater<T>;
17
+ }
18
+ declare function useStore<T>(store: StoreSnapshot<T>): T;
19
+ declare function useStore<T, S>(store: StoreSnapshot<T>, selector: Selector<T, S>, options?: UseStoreOptions<S>): S;
20
+ declare function createReactStore<T>(initialValue: Initializer<T>, options?: StoreOptions<T>): ReactStore<T>;
21
+ declare function createStoreContext<T>(defaultValue: Initializer<T>, options?: StoreOptions<T>): {
22
+ Context: _$react.Context<Store<T> | null>;
23
+ Provider: ({
24
+ children,
25
+ initialValue
26
+ }: StoreProviderProps<T>) => _$react_jsx_runtime0.JSX.Element;
27
+ use: {
28
+ (): T;
29
+ <S>(selector: Selector<T, S>, useOptions?: UseStoreOptions<S>): S;
30
+ };
31
+ useStore: {
32
+ (): T;
33
+ <S>(selector: Selector<T, S>, useOptions?: UseStoreOptions<S>): S;
34
+ };
35
+ useStoreInstance: () => Store<T>;
36
+ useSetState: () => (state: StateUpdater<T>) => T;
37
+ };
38
+ //#endregion
39
+ export { ReactStore, StoreProviderProps, UseStoreOptions, createReactStore, createStoreContext, useStore };
package/dist/react.js ADDED
@@ -0,0 +1,70 @@
1
+ import { i as resolveStateUpdater, n as createStore, r as resolveInitializer, t as Store } from "./store-DblPx3Yb.js";
2
+ import { createContext, useContext, useMemo, useState, useSyncExternalStore } from "react";
3
+ import { jsx } from "react/jsx-runtime";
4
+ //#region src/react.tsx
5
+ const identity = (value) => value;
6
+ function createSelectedSnapshot(store, selector, isEqual) {
7
+ let hasSnapshot = false;
8
+ let lastStoreSnapshot;
9
+ let lastSelectedSnapshot;
10
+ return () => {
11
+ const storeSnapshot = store.getSnapshot();
12
+ if (hasSnapshot && Object.is(storeSnapshot, lastStoreSnapshot)) return lastSelectedSnapshot;
13
+ const selectedSnapshot = selector(storeSnapshot);
14
+ if (hasSnapshot && isEqual(lastSelectedSnapshot, selectedSnapshot)) {
15
+ lastStoreSnapshot = storeSnapshot;
16
+ return lastSelectedSnapshot;
17
+ }
18
+ hasSnapshot = true;
19
+ lastStoreSnapshot = storeSnapshot;
20
+ lastSelectedSnapshot = selectedSnapshot;
21
+ return selectedSnapshot;
22
+ };
23
+ }
24
+ function useStore(store, selector = identity, options) {
25
+ const getSnapshot = useMemo(() => createSelectedSnapshot(store, selector, options?.isEqual ?? Object.is), [
26
+ store,
27
+ selector,
28
+ options?.isEqual
29
+ ]);
30
+ return useSyncExternalStore((onStoreChange) => store.subscribe(onStoreChange), getSnapshot, getSnapshot);
31
+ }
32
+ function createReactStore(initialValue, options) {
33
+ const store = createStore(initialValue, options);
34
+ function useReactStore(selector, useOptions) {
35
+ return useStore(store, selector ?? identity, useOptions);
36
+ }
37
+ store.use = useReactStore;
38
+ return store;
39
+ }
40
+ function createStoreContext(defaultValue, options) {
41
+ const Context = createContext(null);
42
+ function useStoreInstance() {
43
+ const store = useContext(Context);
44
+ if (store === null) throw new Error("Store context is missing a matching Provider");
45
+ return store;
46
+ }
47
+ function Provider({ children, initialValue }) {
48
+ const [store] = useState(() => {
49
+ const resolvedDefaultValue = resolveInitializer(defaultValue);
50
+ return new Store(initialValue === void 0 ? resolvedDefaultValue : resolveStateUpdater(initialValue, resolvedDefaultValue), options);
51
+ });
52
+ return /* @__PURE__ */ jsx(Context.Provider, {
53
+ value: store,
54
+ children
55
+ });
56
+ }
57
+ function useContextStore(selector, useOptions) {
58
+ return useStore(useStoreInstance(), selector ?? identity, useOptions);
59
+ }
60
+ return {
61
+ Context,
62
+ Provider,
63
+ use: useContextStore,
64
+ useStore: useContextStore,
65
+ useStoreInstance,
66
+ useSetState: () => useStoreInstance().setState
67
+ };
68
+ }
69
+ //#endregion
70
+ export { createReactStore, createStoreContext, useStore };
@@ -0,0 +1,33 @@
1
+ //#region src/store.d.ts
2
+ type EqualityFn<T> = (a: T, b: T) => boolean;
3
+ type Initializer<T> = T | (() => T);
4
+ type Selector<T, S = T> = (value: T) => S;
5
+ type StateUpdater<T> = T | ((previous: T) => T);
6
+ type StoreListener<T> = (value: T, previousValue: T) => void;
7
+ type Unsubscribe = () => void;
8
+ interface StoreSnapshot<T> {
9
+ subscribe(listener: () => void): Unsubscribe;
10
+ getSnapshot(): T;
11
+ }
12
+ interface StoreOptions<T> {
13
+ isEqual?: EqualityFn<T>;
14
+ }
15
+ interface SubscribeOptions<T> {
16
+ fireImmediately?: boolean;
17
+ isEqual?: EqualityFn<T>;
18
+ }
19
+ declare function resolveInitializer<T>(value: Initializer<T>): T;
20
+ declare function resolveStateUpdater<T>(value: StateUpdater<T>, previousValue: T): T;
21
+ declare class Store<T> implements StoreSnapshot<T> {
22
+ #private;
23
+ constructor(initialValue: Initializer<T>, options?: StoreOptions<T>);
24
+ getSnapshot: () => T;
25
+ get: () => T;
26
+ setState: (state: StateUpdater<T>) => T;
27
+ set: (state: StateUpdater<T>) => T;
28
+ subscribe: (listener: StoreListener<T>, options?: SubscribeOptions<T>) => Unsubscribe;
29
+ subscribeSelector: <S>(selector: Selector<T, S>, listener: StoreListener<S>, options?: SubscribeOptions<S>) => Unsubscribe;
30
+ }
31
+ declare function createStore<T>(initialValue: Initializer<T>, options?: StoreOptions<T>): Store<T>;
32
+ //#endregion
33
+ export { Store as a, StoreSnapshot as c, createStore as d, resolveInitializer as f, StateUpdater as i, SubscribeOptions as l, Initializer as n, StoreListener as o, resolveStateUpdater as p, Selector as r, StoreOptions as s, EqualityFn as t, Unsubscribe as u };
@@ -0,0 +1,55 @@
1
+ //#region src/store.ts
2
+ const identity = (value) => value;
3
+ function resolveInitializer(value) {
4
+ if (typeof value === "function") return value();
5
+ return value;
6
+ }
7
+ function resolveStateUpdater(value, previousValue) {
8
+ if (typeof value === "function") return value(previousValue);
9
+ return value;
10
+ }
11
+ var Store = class {
12
+ #isEqual;
13
+ #listeners = /* @__PURE__ */ new Set();
14
+ #value;
15
+ constructor(initialValue, options) {
16
+ this.#value = resolveInitializer(initialValue);
17
+ this.#isEqual = options?.isEqual ?? Object.is;
18
+ }
19
+ getSnapshot = () => this.#value;
20
+ get = () => this.#value;
21
+ setState = (state) => {
22
+ const previousValue = this.#value;
23
+ const nextValue = resolveStateUpdater(state, previousValue);
24
+ if (this.#isEqual(previousValue, nextValue)) return this.#value;
25
+ this.#value = nextValue;
26
+ for (const listener of [...this.#listeners]) listener(nextValue, previousValue);
27
+ return nextValue;
28
+ };
29
+ set = this.setState;
30
+ subscribe = (listener, options) => {
31
+ return this.subscribeSelector(identity, listener, options);
32
+ };
33
+ subscribeSelector = (selector, listener, options) => {
34
+ const select = selector;
35
+ const isEqual = options?.isEqual ?? Object.is;
36
+ let selectedValue = select(this.#value);
37
+ const storeListener = (value) => {
38
+ const nextSelectedValue = select(value);
39
+ if (isEqual(selectedValue, nextSelectedValue)) return;
40
+ const previousSelectedValue = selectedValue;
41
+ selectedValue = nextSelectedValue;
42
+ listener(nextSelectedValue, previousSelectedValue);
43
+ };
44
+ this.#listeners.add(storeListener);
45
+ if (options?.fireImmediately) listener(selectedValue, selectedValue);
46
+ return () => {
47
+ this.#listeners.delete(storeListener);
48
+ };
49
+ };
50
+ };
51
+ function createStore(initialValue, options) {
52
+ return new Store(initialValue, options);
53
+ }
54
+ //#endregion
55
+ export { resolveStateUpdater as i, createStore as n, resolveInitializer as r, Store as t };
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@mpen/react-external-state",
3
+ "description": "Small external state stores with React hooks and localStorage persistence.",
4
+ "version": "0.1.0",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/mnpenner/npm-packages.git",
8
+ "directory": "packages/react-external-state"
9
+ },
10
+ "license": "MIT",
11
+ "author": "Mark Penner <npm@mpen.ca>",
12
+ "packageManager": "bun@1.3.6",
13
+ "type": "module",
14
+ "sideEffects": false,
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "exports": {
23
+ ".": "./dist/index.js",
24
+ "./react": "./dist/react.js",
25
+ "./package.json": "./package.json"
26
+ },
27
+ "types": "./dist/index.d.ts",
28
+ "peerDependencies": {
29
+ "react": "^19"
30
+ },
31
+ "peerDependenciesMeta": {
32
+ "react": {
33
+ "optional": true
34
+ }
35
+ },
36
+ "devDependencies": {
37
+ "@types/bun": "latest",
38
+ "@types/react": "^19",
39
+ "react": "catalog:",
40
+ "tsdown": "catalog:",
41
+ "typescript": "^6.0.2"
42
+ },
43
+ "scripts": {
44
+ "build": "bun run --bun tsdown",
45
+ "test": "bun test",
46
+ "typecheck": "tsc --noEmit",
47
+ "_prepublishOnly": "bun run build && bun run test"
48
+ },
49
+ "main": "./dist/index.js",
50
+ "module": "./dist/index.js"
51
+ }