From 71bb147b305288853b9ed85f569fce9e41a20f5e Mon Sep 17 00:00:00 2001 From: Mayank Date: Sun, 19 Jan 2025 17:46:41 +0530 Subject: [PATCH 1/7] Update readme --- README.md | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/README.md b/README.md index 306754ef..a73cbbbd 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,64 @@ useKosha.set({ count: 42, user: "John Doe" }); --- +This post provides a clear comparison between **Kosha** and **Zustand**, emphasizing Kosha's advantages in terms of size, performance, and flexibility. Here’s a brief recap and refinement: + +--- + +### **Why Choose Kosha Over Zustand?** + +1. **Lighter & Faster** + + - Kosha’s **minzipped size** is only **420 bytes**, making it ideal for performance-critical projects. + - Zustand is heavier, which could impact apps where every kilobyte counts. + +2. **Optimized Selectors** + + - Kosha ensures **zero unnecessary re-renders** out of the box—components only re-render when the selector output changes. + Example: + + ```tsx + const count = useKosha(state => state.count); + ``` + + or + + ```tsx + const fullName = useKosha(state => state.firstName + state.lastName); + ``` + + - Zustand requires explicit optimizations and may still trigger redundant re-renders. See the [Zustand docs](https://github.com/pmndrs/zustand/blob/37e1e3f193a5e5dec6fbd0f07514aec59a187e01/docs/guides/prevent-rerenders-with-use-shallow.md). + +3. **Built-in Partial Updates** + + - Kosha simplifies **state updates** with clean syntax, no need to spread the previous state manually: + + ```tsx + set({ count }); // Update 'count' only + + set(state => ({ count: state.count + 1 })); // Increment 'count' + ``` + + - Zustand also supports partial updates in newer versions, but Kosha delivers this efficiency in a smaller footprint. + +4. **Flexible API** + - Kosha allows consuming the entire store when needed: + ```tsx + const { count, setCount } = useKosha(); + ``` + +--- + +### When to Use Kosha? + +Choose **Kosha** if your project prioritizes: + +- Minimal bundle size. +- Performance and selector efficiency. +- Modern state management with a lean API. + +For larger projects or those already using Zustand’s ecosystem, Kosha offers a streamlined alternative. + ## 📌 FAQ ### 1. Does Kosha support async actions? From a41b630c901b736a7ce911802246c8609fb5d2b2 Mon Sep 17 00:00:00 2001 From: Mayank Date: Mon, 20 Jan 2025 09:59:33 +0530 Subject: [PATCH 2/7] Add persist middleware --- lib/src/index.ts | 13 ++++++--- lib/src/middleware/persist.ts | 53 +++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 lib/src/middleware/persist.ts diff --git a/lib/src/index.ts b/lib/src/index.ts index cb72d552..bcec0992 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -11,11 +11,16 @@ import { useSyncExternalStore } from "react"; type ListenerWithSelector = [listener: () => void, selectorFunc?: (state: T) => U]; -type StateSetterArgType = ((newState: T) => Partial) | Partial | T; +export type StateSetterArgType = ((newState: T) => Partial) | Partial | T; -export const create = ( - storeCreator: (set: (state: StateSetterArgType) => void, get: () => T | null) => T, -) => { +export type StoreCreator = ( + set: (state: StateSetterArgType) => void, + get: () => T | null, +) => T; + +export type Middleware = (storeCreator: StoreCreator) => StoreCreator; + +export const create = (storeCreator: StoreCreator) => { const listeners = new Set>(); const stateRef: { k: T | null } = { k: null }; const get = () => stateRef.k; diff --git a/lib/src/middleware/persist.ts b/lib/src/middleware/persist.ts new file mode 100644 index 00000000..7a3113e5 --- /dev/null +++ b/lib/src/middleware/persist.ts @@ -0,0 +1,53 @@ +import { Middleware, StateSetterArgType } from ".."; + +export interface PersistOptions { + /** Storage Key (must be unique) */ + key: string; + /** + * Use a custom persist storage. + * + * Combining `createJSONStorage` helps creating a persist storage + * with JSON.parse and JSON.stringify. + * + * @default createJSONStorage(() => localStorage) + */ + partialize?: (state: Partial) => PersistedState; + /** + * If the stored state's version mismatch the one specified here, the storage will not be used. + * This is useful when adding a breaking change to your store. + */ + version?: number; + /** + * A function to perform persisted state migration. + * This function will be called when persisted state versions mismatch with the one specified here. + */ + migrate?: (persistedState: unknown, version: number) => PersistedState | Promise; +} + +export const persist = (options: PersistOptions): Middleware => { + if (typeof localStorage === "undefined") return stateCreator => stateCreator; + return stateCreator => (set, get) => { + const onStorageChange = () => { + const persistedVal = localStorage.getItem(options.key); + if (!persistedVal) return; + const parsed = JSON.parse(persistedVal); + if (parsed.version !== options.version) { + if (options.migrate) options.migrate(parsed.state, parsed.version); + } else return; + set(parsed.state); + }; + onstorage = onStorageChange; + onStorageChange(); + const persistSetter = (newStatePartial: StateSetterArgType) => { + const newState = + newStatePartial instanceof Function ? newStatePartial(get()!) : newStatePartial; + const partial = options.partialize ? options.partialize(newState) : newState; + localStorage.setItem( + options.key, + JSON.stringify({ state: partial, version: options.version }), + ); + set(newStatePartial); + }; + return stateCreator(persistSetter, get); + }; +}; From bf86303b986d369c3c53685f2025b182bbd5cc67 Mon Sep 17 00:00:00 2001 From: Mayank Date: Mon, 20 Jan 2025 10:00:25 +0530 Subject: [PATCH 3/7] docs(changeset): Add persist middleware and export types for easing out middleware development. --- .changeset/sixty-books-admire.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/sixty-books-admire.md diff --git a/.changeset/sixty-books-admire.md b/.changeset/sixty-books-admire.md new file mode 100644 index 00000000..214ebf89 --- /dev/null +++ b/.changeset/sixty-books-admire.md @@ -0,0 +1,5 @@ +--- +"kosha": minor +--- + +Add persist middleware and export types for easing out middleware development. From ff41fcaa5e6540c626053788c9a10d7ce297de84 Mon Sep 17 00:00:00 2001 From: Mayank Date: Fri, 24 Jan 2025 15:07:34 +0530 Subject: [PATCH 4/7] docs(changeset): Add persist middleware and update library to support middlewares --- .changeset/sharp-kings-pull.md | 5 ++ README.md | 19 +----- lib/src/index.ts | 21 +++--- lib/src/middleware/persist.ts | 64 ++++++++++++------- .../counter-without-selectors.tsx | 3 +- lib/tests/with-selectors/store.ts | 4 +- lib/tests/with-selectors/user-data.tsx | 2 +- packages/shared/src/client/demo/demo.tsx | 6 ++ .../shared/src/client/demo/persist/index.tsx | 32 ++++++++++ .../counter-without-selectors.tsx | 3 +- .../src/client/demo/with-selectors/store.ts | 4 +- .../client/demo/with-selectors/user-data.tsx | 2 +- 12 files changed, 107 insertions(+), 58 deletions(-) create mode 100644 .changeset/sharp-kings-pull.md create mode 100644 packages/shared/src/client/demo/persist/index.tsx diff --git a/.changeset/sharp-kings-pull.md b/.changeset/sharp-kings-pull.md new file mode 100644 index 00000000..6d527494 --- /dev/null +++ b/.changeset/sharp-kings-pull.md @@ -0,0 +1,5 @@ +--- +"kosha": minor +--- + +Add persist middleware and update library to support middlewares diff --git a/README.md b/README.md index a73cbbbd..0966067c 100644 --- a/README.md +++ b/README.md @@ -30,21 +30,14 @@ Kosha is a minimal global state management solution tailored for modern React ap set(state => ({ count: state.count + 1 })); ``` -4. **Direct Store Updates** - - - Use `useKosha.set` to update the entire store directly: - ```tsx - useKosha.set({ count: 42, user: "John Doe" }); - ``` - -5. **Flexible Consumption** +4. **Flexible Consumption** - Use the entire store or specific selectors as needed: ```tsx const { count, setCount } = useKosha(); ``` -6. **Concurrent Rendering Ready** +5. **Concurrent Rendering Ready** - Built on React’s `useSyncExternalStore`, ensuring compatibility with React 18+ features. --- @@ -115,14 +108,6 @@ const Counter = () => { }; ``` -### Direct Store Updates - -You can also use `useKosha.set` to update the entire store directly: - -```tsx -useKosha.set({ count: 42, user: "John Doe" }); -``` - --- This post provides a clear comparison between **Kosha** and **Zustand**, emphasizing Kosha's advantages in terms of size, performance, and flexibility. Here’s a brief recap and refinement: diff --git a/lib/src/index.ts b/lib/src/index.ts index bcec0992..2f4ed753 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -10,20 +10,21 @@ import { useSyncExternalStore } from "react"; +export type BaseType = Omit; type ListenerWithSelector = [listener: () => void, selectorFunc?: (state: T) => U]; export type StateSetterArgType = ((newState: T) => Partial) | Partial | T; -export type StoreCreator = ( +export type StoreCreator = ( set: (state: StateSetterArgType) => void, get: () => T | null, -) => T; +) => T & { __get?: () => T | null }; -export type Middleware = (storeCreator: StoreCreator) => StoreCreator; +export type Middleware = (storeCreator: StoreCreator) => StoreCreator; -export const create = (storeCreator: StoreCreator) => { +export const create = (storeCreator: StoreCreator) => { const listeners = new Set>(); const stateRef: { k: T | null } = { k: null }; - const get = () => stateRef.k; + let get = () => stateRef.k; const set = (newState: StateSetterArgType) => { const oldState = stateRef.k; const partial = newState instanceof Function ? newState(stateRef.k!) : newState; @@ -39,7 +40,10 @@ export const create = (storeCreator: StoreCreator) => { ); }; - stateRef.k = storeCreator(set, get); + const { __get, ...rest } = storeCreator(set, get); + // @ts-expect-error -- will fix + stateRef.k = rest; + get = __get ?? get; /** * A React hook to access the store's state or derived slices of it. @@ -50,13 +54,13 @@ export const create = (storeCreator: StoreCreator) => { const map = new Map<(state: T) => unknown, unknown>(); const useHook = (selectorFunc?: (state: T) => U): U => { const getSlice = () => { - const newValue = selectorFunc!(stateRef.k!); + const newValue = selectorFunc!(get()!); const obj = map.get(selectorFunc!); const finalValue = JSON.stringify(obj) === JSON.stringify(newValue) ? obj : newValue; map.set(selectorFunc!, finalValue); return finalValue as U; }; - const getSnapshot = () => (selectorFunc ? getSlice() : stateRef.k) as U; + const getSnapshot = () => (selectorFunc ? getSlice() : get()) as U; return useSyncExternalStore( listener => { const listenerWithSelector = [listener, selectorFunc] as ListenerWithSelector; @@ -68,6 +72,5 @@ export const create = (storeCreator: StoreCreator) => { ); }; - useHook.set = set; return useHook; }; diff --git a/lib/src/middleware/persist.ts b/lib/src/middleware/persist.ts index 7a3113e5..f63c1be2 100644 --- a/lib/src/middleware/persist.ts +++ b/lib/src/middleware/persist.ts @@ -1,17 +1,14 @@ -import { Middleware, StateSetterArgType } from ".."; +import { BaseType, Middleware, StateSetterArgType } from ".."; -export interface PersistOptions { +export interface PersistOptions { /** Storage Key (must be unique) */ key: string; /** - * Use a custom persist storage. + * Filter the persisted value. * - * Combining `createJSONStorage` helps creating a persist storage - * with JSON.parse and JSON.stringify. - * - * @default createJSONStorage(() => localStorage) + * @params state The state's value */ - partialize?: (state: Partial) => PersistedState; + partialize?: (state: S) => Partial; /** * If the stored state's version mismatch the one specified here, the storage will not be used. * This is useful when adding a breaking change to your store. @@ -21,33 +18,52 @@ export interface PersistOptions { * A function to perform persisted state migration. * This function will be called when persisted state versions mismatch with the one specified here. */ - migrate?: (persistedState: unknown, version: number) => PersistedState | Promise; + migrate?: (persistedState: unknown, version: number) => Partial | Promise>; } -export const persist = (options: PersistOptions): Middleware => { - if (typeof localStorage === "undefined") return stateCreator => stateCreator; - return stateCreator => (set, get) => { +export const persist = + (options: PersistOptions): Middleware => + stateCreator => + (set, get) => { + let isSynced = false; const onStorageChange = () => { const persistedVal = localStorage.getItem(options.key); if (!persistedVal) return; const parsed = JSON.parse(persistedVal); - if (parsed.version !== options.version) { - if (options.migrate) options.migrate(parsed.state, parsed.version); - } else return; - set(parsed.state); + if (options.version === undefined || options.version === parsed.version) { + console.log({ parsed }); + set(parsed.state); + } else if (options.migrate) { + const newState = options.migrate(parsed.state, parsed.version); + if (newState instanceof Promise) { + newState.then(newState => { + set(newState); + }); + } else { + set(newState); + } + } }; - onstorage = onStorageChange; - onStorageChange(); const persistSetter = (newStatePartial: StateSetterArgType) => { - const newState = - newStatePartial instanceof Function ? newStatePartial(get()!) : newStatePartial; - const partial = options.partialize ? options.partialize(newState) : newState; + const newState = { + ...get(), + ...(newStatePartial instanceof Function ? newStatePartial(get()!) : newStatePartial), + }; + const partial = options.partialize ? options.partialize(newState as T) : newState; localStorage.setItem( options.key, JSON.stringify({ state: partial, version: options.version }), ); - set(newStatePartial); + set(newState); + }; + const persistGetter = () => { + console.log("persist getter called --- "); + if (!isSynced && typeof window !== "undefined") { + onStorageChange(); + window.addEventListener("storage", onStorageChange); + isSynced = true; + } + return get(); }; - return stateCreator(persistSetter, get); + return { ...stateCreator(persistSetter, get), __get: persistGetter }; }; -}; diff --git a/lib/tests/with-selectors/counter-without-selectors.tsx b/lib/tests/with-selectors/counter-without-selectors.tsx index 818457f5..07c97c0f 100644 --- a/lib/tests/with-selectors/counter-without-selectors.tsx +++ b/lib/tests/with-selectors/counter-without-selectors.tsx @@ -2,8 +2,7 @@ import { useRef } from "react"; import { useMyKosha } from "./store"; export function CounterWithoutSelectors() { - const { count } = useMyKosha(); - const setState = useMyKosha.set; + const { count, set: setState } = useMyKosha(); const renderCount = useRef(0); renderCount.current++; return ( diff --git a/lib/tests/with-selectors/store.ts b/lib/tests/with-selectors/store.ts index 76b387cf..9cb0c3b7 100644 --- a/lib/tests/with-selectors/store.ts +++ b/lib/tests/with-selectors/store.ts @@ -1,4 +1,4 @@ -import { create } from "../../src"; +import { create, StateSetterArgType } from "../../src"; interface MyKosha { count: number; @@ -8,6 +8,7 @@ interface MyKosha { age: number; }; setCount: (count: number) => void; + set: (state: StateSetterArgType) => void; } export const useMyKosha = create(set => ({ @@ -18,4 +19,5 @@ export const useMyKosha = create(set => ({ age: 30, }, setCount: (count: number) => set(state => ({ ...state, count })), + set, })); diff --git a/lib/tests/with-selectors/user-data.tsx b/lib/tests/with-selectors/user-data.tsx index f2e68916..914083c6 100644 --- a/lib/tests/with-selectors/user-data.tsx +++ b/lib/tests/with-selectors/user-data.tsx @@ -3,7 +3,7 @@ import { useMyKosha } from "./store"; export function UserData() { const user = useMyKosha(state => state.user); - const setState = useMyKosha.set; + const setState = useMyKosha(state => state.set); const renderCount = useRef(0); renderCount.current++; const onSubmit = (e: React.FormEvent) => { diff --git a/packages/shared/src/client/demo/demo.tsx b/packages/shared/src/client/demo/demo.tsx index dd352ca7..553dd951 100644 --- a/packages/shared/src/client/demo/demo.tsx +++ b/packages/shared/src/client/demo/demo.tsx @@ -12,6 +12,8 @@ import couter2Code from "./with-selectors/counter.tsx?raw"; import counterWithoutSelectorscode from "./with-selectors/counter-without-selectors.tsx?raw"; import headerCode from "./with-selectors/header.tsx?raw"; import userDataCode from "./with-selectors/user-data.tsx?raw"; +import { PersistedCounter } from "./persist"; +import PersistedCounterCode from "./persist?raw"; const basicExCode = [ { filename: "counter.tsx", code: counterCode }, @@ -43,6 +45,10 @@ export function Demo() { +
+ + +
); } diff --git a/packages/shared/src/client/demo/persist/index.tsx b/packages/shared/src/client/demo/persist/index.tsx new file mode 100644 index 00000000..082389ff --- /dev/null +++ b/packages/shared/src/client/demo/persist/index.tsx @@ -0,0 +1,32 @@ +import { create } from "kosha"; +import { persist } from "kosha/dist/middleware/persist"; + +interface CounterStore { + count: number; + localCount: number; + setCount: (count: number) => void; + setLocalCount: (count: number) => void; +} + +const usePersistedKosha = create( + persist({ key: "test-kosha", partialize: state => ({ count: state.count }) })( + set => ({ + count: 0, + localCount: 0, + setCount: (count: number) => set({ count }), + setLocalCount: localCount => set({ localCount }), + }), + ), +); + +export const PersistedCounter = () => { + const { count, localCount, setCount, setLocalCount } = usePersistedKosha(); + return ( +
+
Count: {count}
+ +
Local Count: {localCount}
+ +
+ ); +}; diff --git a/packages/shared/src/client/demo/with-selectors/counter-without-selectors.tsx b/packages/shared/src/client/demo/with-selectors/counter-without-selectors.tsx index 818457f5..07c97c0f 100644 --- a/packages/shared/src/client/demo/with-selectors/counter-without-selectors.tsx +++ b/packages/shared/src/client/demo/with-selectors/counter-without-selectors.tsx @@ -2,8 +2,7 @@ import { useRef } from "react"; import { useMyKosha } from "./store"; export function CounterWithoutSelectors() { - const { count } = useMyKosha(); - const setState = useMyKosha.set; + const { count, set: setState } = useMyKosha(); const renderCount = useRef(0); renderCount.current++; return ( diff --git a/packages/shared/src/client/demo/with-selectors/store.ts b/packages/shared/src/client/demo/with-selectors/store.ts index 6607a8a6..dc8c233e 100644 --- a/packages/shared/src/client/demo/with-selectors/store.ts +++ b/packages/shared/src/client/demo/with-selectors/store.ts @@ -1,4 +1,4 @@ -import { create } from "kosha"; +import { create, StateSetterArgType } from "kosha"; interface MyKosha { count: number; @@ -8,6 +8,7 @@ interface MyKosha { age: number; }; setCount: (count: number) => void; + set: (state: StateSetterArgType) => void; } export const useMyKosha = create(set => ({ @@ -18,4 +19,5 @@ export const useMyKosha = create(set => ({ age: 30, }, setCount: (count: number) => set(state => ({ ...state, count })), + set, })); diff --git a/packages/shared/src/client/demo/with-selectors/user-data.tsx b/packages/shared/src/client/demo/with-selectors/user-data.tsx index f2e68916..914083c6 100644 --- a/packages/shared/src/client/demo/with-selectors/user-data.tsx +++ b/packages/shared/src/client/demo/with-selectors/user-data.tsx @@ -3,7 +3,7 @@ import { useMyKosha } from "./store"; export function UserData() { const user = useMyKosha(state => state.user); - const setState = useMyKosha.set; + const setState = useMyKosha(state => state.set); const renderCount = useRef(0); renderCount.current++; const onSubmit = (e: React.FormEvent) => { From c52f018148536e19ab757e5633d7ef835adf099a Mon Sep 17 00:00:00 2001 From: Mayank Date: Fri, 24 Jan 2025 15:09:00 +0530 Subject: [PATCH 5/7] docs(changeset): Remove .set method on the hook. --- .changeset/stupid-hounds-sit.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/stupid-hounds-sit.md diff --git a/.changeset/stupid-hounds-sit.md b/.changeset/stupid-hounds-sit.md new file mode 100644 index 00000000..fcbe114a --- /dev/null +++ b/.changeset/stupid-hounds-sit.md @@ -0,0 +1,5 @@ +--- +"kosha": major +--- + +Remove .set method on the hook. From 985a5f5da0bc9248387d31f52c5da8388722bae2 Mon Sep 17 00:00:00 2001 From: Mayank Date: Fri, 24 Jan 2025 15:19:56 +0530 Subject: [PATCH 6/7] Update readme --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 0966067c..ba2a4a89 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,22 @@ const Counter = () => { }; ``` +### Direct Store Updates + +In the latest version, the `.set` method has been removed from the hook. This means `useKosha.set` is no longer available by default. + +To use the `set` method, you must explicitly expose it within your store: + +```typescript +import { create } from "kosha"; + +const useKosha = create(set => ({ + count: 0, + increment: () => set(state => ({ count: state.count + 1 })), + set, // <- Expose the set method to use it as a standard setter with full functionality +})); +``` + --- This post provides a clear comparison between **Kosha** and **Zustand**, emphasizing Kosha's advantages in terms of size, performance, and flexibility. Here’s a brief recap and refinement: From bb985119645563a46445786d98537e336409935f Mon Sep 17 00:00:00 2001 From: Mayank Date: Fri, 24 Jan 2025 15:22:58 +0530 Subject: [PATCH 7/7] Do not include middleware in test coverage for now --- lib/vitest.config.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/vitest.config.mts b/lib/vitest.config.mts index 8eff9cb5..eb534477 100644 --- a/lib/vitest.config.mts +++ b/lib/vitest.config.mts @@ -11,7 +11,7 @@ export default defineConfig({ setupFiles: [], coverage: { include: ["src/**"], - exclude: ["src/**/*.test.*", "src/**/declaration.d.ts"], + exclude: ["src/**/*.test.*", "src/**/declaration.d.ts", "src/middleware/**"], reporter: ["text", "json", "clover", "html"], }, },