Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sharp-kings-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kosha": minor
---

Add persist middleware and update library to support middlewares
5 changes: 5 additions & 0 deletions .changeset/sixty-books-admire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kosha": minor
---

Add persist middleware and export types for easing out middleware development.
5 changes: 5 additions & 0 deletions .changeset/stupid-hounds-sit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kosha": major
---

Remove .set method on the hook.
83 changes: 71 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down Expand Up @@ -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?
Expand Down
26 changes: 17 additions & 9 deletions lib/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,21 @@

import { useSyncExternalStore } from "react";

export type BaseType = Omit<object, "__get">;
type ListenerWithSelector<T, U> = [listener: () => void, selectorFunc?: (state: T) => U];
type StateSetterArgType<T> = ((newState: T) => Partial<T>) | Partial<T> | T;
export type StateSetterArgType<T> = ((newState: T) => Partial<T>) | Partial<T> | T;

export const create = <T extends object>(
storeCreator: (set: (state: StateSetterArgType<T>) => void, get: () => T | null) => T,
) => {
export type StoreCreator<T extends BaseType> = (
set: (state: StateSetterArgType<T>) => void,
get: () => T | null,
) => T & { __get?: () => T | null };

export type Middleware<T extends BaseType> = (storeCreator: StoreCreator<T>) => StoreCreator<T>;

export const create = <T extends BaseType>(storeCreator: StoreCreator<T>) => {
const listeners = new Set<ListenerWithSelector<T, unknown>>();
const stateRef: { k: T | null } = { k: null };
const get = () => stateRef.k;
let get = () => stateRef.k;
const set = (newState: StateSetterArgType<T>) => {
const oldState = stateRef.k;
const partial = newState instanceof Function ? newState(stateRef.k!) : newState;
Expand All @@ -34,7 +40,10 @@ export const create = <T extends object>(
);
};

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.
Expand All @@ -45,13 +54,13 @@ export const create = <T extends object>(
const map = new Map<(state: T) => unknown, unknown>();
const useHook = <U = T>(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<T, U>;
Expand All @@ -63,6 +72,5 @@ export const create = <T extends object>(
);
};

useHook.set = set;
return useHook;
};
69 changes: 69 additions & 0 deletions lib/src/middleware/persist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { BaseType, Middleware, StateSetterArgType } from "..";

export interface PersistOptions<S> {
/** Storage Key (must be unique) */
key: string;
/**
* Filter the persisted value.
*
* @params state The state's value
*/
partialize?: (state: S) => Partial<S>;
/**
* 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<S> | Promise<Partial<S>>;
}

export const persist =
<T extends BaseType>(options: PersistOptions<T>): Middleware<T> =>
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<T>) => {
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 };
};
3 changes: 1 addition & 2 deletions lib/tests/with-selectors/counter-without-selectors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
4 changes: 3 additions & 1 deletion lib/tests/with-selectors/store.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { create } from "../../src";
import { create, StateSetterArgType } from "../../src";

interface MyKosha {
count: number;
Expand All @@ -8,6 +8,7 @@ interface MyKosha {
age: number;
};
setCount: (count: number) => void;
set: (state: StateSetterArgType<MyKosha>) => void;
}

export const useMyKosha = create<MyKosha>(set => ({
Expand All @@ -18,4 +19,5 @@ export const useMyKosha = create<MyKosha>(set => ({
age: 30,
},
setCount: (count: number) => set(state => ({ ...state, count })),
set,
}));
2 changes: 1 addition & 1 deletion lib/tests/with-selectors/user-data.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLFormElement>) => {
Expand Down
2 changes: 1 addition & 1 deletion lib/vitest.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
},
},
Expand Down
6 changes: 6 additions & 0 deletions packages/shared/src/client/demo/demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -43,6 +45,10 @@ export function Demo() {
<WithSelector />
<CodeDisplay code={withSelectorExCode} />
</div>
<div className={styles.demo}>
<PersistedCounter />
<CodeDisplay code={[{ filename: "index.tsx", code: PersistedCounterCode }]} />
</div>
</>
);
}
32 changes: 32 additions & 0 deletions packages/shared/src/client/demo/persist/index.tsx
Original file line number Diff line number Diff line change
@@ -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<CounterStore>({ 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 (
<div>
<div>Count: {count}</div>
<button onClick={() => setCount(count + 1)}>Increment</button>
<div>Local Count: {localCount}</div>
<button onClick={() => setLocalCount(localCount + 1)}>Increment</button>
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
4 changes: 3 additions & 1 deletion packages/shared/src/client/demo/with-selectors/store.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { create } from "kosha";
import { create, StateSetterArgType } from "kosha";

interface MyKosha {
count: number;
Expand All @@ -8,6 +8,7 @@ interface MyKosha {
age: number;
};
setCount: (count: number) => void;
set: (state: StateSetterArgType<MyKosha>) => void;
}

export const useMyKosha = create<MyKosha>(set => ({
Expand All @@ -18,4 +19,5 @@ export const useMyKosha = create<MyKosha>(set => ({
age: 30,
},
setCount: (count: number) => set(state => ({ ...state, count })),
set,
}));
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLFormElement>) => {
Expand Down
Loading