diff --git a/.changeset/sharp-kings-pull.md b/.changeset/sharp-kings-pull.md new file mode 100644 index 000000000..6d5274944 --- /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/.changeset/sixty-books-admire.md b/.changeset/sixty-books-admire.md new file mode 100644 index 000000000..214ebf899 --- /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. diff --git a/.changeset/stupid-hounds-sit.md b/.changeset/stupid-hounds-sit.md new file mode 100644 index 000000000..fcbe114a3 --- /dev/null +++ b/.changeset/stupid-hounds-sit.md @@ -0,0 +1,5 @@ +--- +"kosha": major +--- + +Remove .set method on the hook. diff --git a/README.md b/README.md index 306754ef3..ba2a4a898 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. --- @@ -117,14 +110,80 @@ const Counter = () => { ### Direct Store Updates -You can also use `useKosha.set` to update the entire store directly: +In the latest version, the `.set` method has been removed from the hook. This means `useKosha.set` is no longer available by default. -```tsx -useKosha.set({ count: 42, user: "John Doe" }); +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: + +--- + +### **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? diff --git a/lib/src/index.ts b/lib/src/index.ts index cb72d5523..2f4ed7539 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -10,15 +10,21 @@ import { useSyncExternalStore } from "react"; +export type BaseType = Omit; 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 & { __get?: () => T | null }; + +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; + let get = () => stateRef.k; const set = (newState: StateSetterArgType) => { const oldState = stateRef.k; const partial = newState instanceof Function ? newState(stateRef.k!) : newState; @@ -34,7 +40,10 @@ export const create = ( ); }; - 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. @@ -45,13 +54,13 @@ export const create = ( 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; @@ -63,6 +72,5 @@ export const create = ( ); }; - useHook.set = set; return useHook; }; diff --git a/lib/src/middleware/persist.ts b/lib/src/middleware/persist.ts new file mode 100644 index 000000000..f63c1be25 --- /dev/null +++ b/lib/src/middleware/persist.ts @@ -0,0 +1,69 @@ +import { BaseType, Middleware, StateSetterArgType } from ".."; + +export interface PersistOptions { + /** Storage Key (must be unique) */ + key: string; + /** + * Filter the persisted value. + * + * @params state The state's value + */ + 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. + */ + 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) => Partial | Promise>; +} + +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 (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); + } + } + }; + const persistSetter = (newStatePartial: StateSetterArgType) => { + 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(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), __get: persistGetter }; + }; diff --git a/lib/tests/with-selectors/counter-without-selectors.tsx b/lib/tests/with-selectors/counter-without-selectors.tsx index 818457f5d..07c97c0f4 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 76b387cfb..9cb0c3b78 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 f2e68916e..914083c6a 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/lib/vitest.config.mts b/lib/vitest.config.mts index 8eff9cb5b..eb5344775 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"], }, }, diff --git a/packages/shared/src/client/demo/demo.tsx b/packages/shared/src/client/demo/demo.tsx index dd352ca71..553dd951d 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 000000000..082389ffb --- /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 818457f5d..07c97c0f4 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 6607a8a67..dc8c233e7 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 f2e68916e..914083c6a 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) => {