From 4958a49b6993317787caa5957dd9de4aff794569 Mon Sep 17 00:00:00 2001 From: Yad Smood <1415488+ysmood@users.noreply.github.com> Date: Fri, 11 Oct 2024 17:32:28 +0800 Subject: [PATCH] refactor to utils module and middlewares --- .eslintrc.cjs | 2 +- README.md | 6 +- cspell.json | 13 +++ examples/CounterPersistent.tsx | 8 +- examples/MonolithStore/store/index.ts | 2 +- examples/TodoApp/Actions.tsx | 4 +- examples/TodoApp/Filter.tsx | 6 +- examples/TodoApp/README.md | 19 ++++ examples/TodoApp/Title.tsx | 2 +- examples/TodoApp/TodoItem.tsx | 48 ++++++--- examples/TodoApp/TodoList.tsx | 2 +- examples/TodoApp/ToggleAll.tsx | 4 +- examples/TodoApp/{todos => store}/actions.ts | 22 ++-- examples/TodoApp/store/filter/index.ts | 12 +++ examples/TodoApp/store/filter/types.ts | 5 + examples/TodoApp/store/index.ts | 19 ++++ examples/TodoApp/store/logger.ts | 15 +++ examples/TodoApp/store/todos/index.ts | 80 ++++++++++++++ examples/TodoApp/store/todos/types.ts | 7 ++ examples/{ => TodoApp/store/todos}/utils.ts | 0 examples/TodoApp/todos/filter.ts | 13 --- examples/TodoApp/todos/index.ts | 86 --------------- examples/app.tsx | 48 +++++++++ examples/index.tsx | 36 +------ package.json | 5 +- src/immer.test.tsx | 20 +++- src/immer.ts | 27 ++--- src/index.test.tsx | 28 +---- src/index.ts | 71 ++++++------ src/persistent.test.tsx | 9 +- src/persistent.ts | 107 +++++++++---------- src/utils.test.tsx | 51 +++++++++ src/utils.ts | 47 ++++++++ 33 files changed, 507 insertions(+), 317 deletions(-) create mode 100644 cspell.json create mode 100644 examples/TodoApp/README.md rename examples/TodoApp/{todos => store}/actions.ts (58%) create mode 100644 examples/TodoApp/store/filter/index.ts create mode 100644 examples/TodoApp/store/filter/types.ts create mode 100644 examples/TodoApp/store/index.ts create mode 100644 examples/TodoApp/store/logger.ts create mode 100644 examples/TodoApp/store/todos/index.ts create mode 100644 examples/TodoApp/store/todos/types.ts rename examples/{ => TodoApp/store/todos}/utils.ts (100%) delete mode 100644 examples/TodoApp/todos/filter.ts delete mode 100644 examples/TodoApp/todos/index.ts create mode 100644 examples/app.tsx create mode 100644 src/utils.test.tsx create mode 100644 src/utils.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index c5bc1ed..613752e 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -14,6 +14,6 @@ module.exports = { 'warn', { allowConstantExport: true }, ], - 'no-console': ['error', { allow: ['warn', 'error'] }], + 'no-console': ['error', { allow: ['info', 'warn', 'error'] }], }, } diff --git a/README.md b/README.md index d9d356c..5f8a388 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Overview An elegant state management solution for React. +The philosophy of this project is to keep the core simple and scalable by exposing low-level accessibility and middleware composition, not by adding options. +All the non-core functions are just examples of how you can compose functions to achieve common features. ## Features @@ -8,7 +10,8 @@ An elegant state management solution for React. - **Type safe**: The state is type safe and the return value is intuitive. - **Global**: The state is global, you can access it anywhere. - **Scalable**: Naturally scale large state into multiple modules and files without performance degradation. -- **Tiny**: Less than [0.3KB](https://bundlephobia.com/package/create-global-state). +- **Middlewares**: Simple and type-safe middleware composition interface. +- **Tiny**: About [0.3KB](https://bundlephobia.com/package/create-global-state) Minified + Gzipped. ## Documentation @@ -46,3 +49,4 @@ Its implementation is not type safe and the return value is not intuitive. It's The typescript support is not good enough, the API is not intuitive. `create-global-state` is more like `useState` which aligns with the react API style. Check the [comparison](https://github.com/ysmood/create-global-state/issues/1). Zustand [Slices Pattern](https://zustand.docs.pmnd.rs/guides/slices-pattern) can cause naming conflict issues. `create-global-state` can naturally scale states by modules and files. +The setter of zustand must be called within a react component, while the setter of `create-global-state` can be called anywhere. diff --git a/cspell.json b/cspell.json new file mode 100644 index 0000000..b958163 --- /dev/null +++ b/cspell.json @@ -0,0 +1,13 @@ +{ + "words": [ + "gettify", + "immer", + "immerify", + "subscribify", + "todomvc", + "todos", + "tsbuildinfo", + "wouter", + "zustand" + ] +} diff --git a/examples/CounterPersistent.tsx b/examples/CounterPersistent.tsx index c591094..946258a 100644 --- a/examples/CounterPersistent.tsx +++ b/examples/CounterPersistent.tsx @@ -1,4 +1,4 @@ -import create from "create-global-state/lib/persistent"; +import create, { saveHistory } from "create-global-state/lib/persistent"; import { useEffect } from "react"; import { useHashLocation } from "wouter/use-hash-location"; @@ -22,7 +22,11 @@ export default function CounterPersistent() { } function Button() { - return ; + return ( + + ); } function Display() { diff --git a/examples/MonolithStore/store/index.ts b/examples/MonolithStore/store/index.ts index 6568fb4..ffce86f 100644 --- a/examples/MonolithStore/store/index.ts +++ b/examples/MonolithStore/store/index.ts @@ -1,6 +1,6 @@ // Here we use Immer to update the store immutably, Immer is optional, // you can use vanilla js or other libs to update the store. -import create from "create-global-state/lib/immer"; +import { create } from "create-global-state/lib/immer"; // Define the monolith store for the entire web app. // It contains a list of counters and a title string. diff --git a/examples/TodoApp/Actions.tsx b/examples/TodoApp/Actions.tsx index 4f3262e..478bbc7 100644 --- a/examples/TodoApp/Actions.tsx +++ b/examples/TodoApp/Actions.tsx @@ -1,6 +1,6 @@ import Filter from "./Filter"; -import { addTodo, clearCompleted } from "./todos/actions"; -import { useZeroDone } from "./todos"; +import { addTodo, clearCompleted } from "./store/actions"; +import { useZeroDone } from "./store/todos"; import ToggleAll from "./ToggleAll"; export default function Actions() { diff --git a/examples/TodoApp/Filter.tsx b/examples/TodoApp/Filter.tsx index a80ad3c..516a566 100644 --- a/examples/TodoApp/Filter.tsx +++ b/examples/TodoApp/Filter.tsx @@ -1,7 +1,11 @@ -import { filters, setFilter } from "./todos/filter"; +import { useEffect } from "react"; +import { setFilter } from "./store/filter"; +import { filters } from "./store/filter/types"; // The component to filter the todos. export default function Filter() { + useEffect(() => {}); + return ( toggleTodo(id)} /> - - updateTodo(id, e.target.value)} - disabled={done} - autoFocus - /> + + ; + return ( + + ); } function B() { - const val = useVal(); + const { val } = useVal(); return
{val ? "OK" : ""}
; } @@ -28,7 +38,7 @@ it("immer", async () => { }); it("immer replace", async () => { - const [useVal, setVal] = createImmer(false); + const [useVal, setVal] = create(false); function A() { return ; diff --git a/src/immer.ts b/src/immer.ts index d5c5fc9..8498b35 100644 --- a/src/immer.ts +++ b/src/immer.ts @@ -1,27 +1,16 @@ -import createState from "."; -import { produce as baseProduce } from "immer"; - -/** - * The producer is used to update the state immutably. - * If it's a function, it will be called with the current draft state. - * If it's a value, the state will be replaced with the value. - * @param draft The current draft state you will make changes on. - * @returns If you return a new state, the state will be updated with the new state. - * If you return nothing, the draft will be used to update the state. - */ -export type Producer = T | ((draft: T) => T | void); +import createState, { producer, NextState } from "."; +import { produce } from "immer"; +import { compose, SetStoreX } from "./utils"; /** * It's similar to the base create function, but it uses Immer to update the state immutably. */ -export default function create(init: T) { +export function create(init: S) { const [useStore, setStore] = createState(init); - return [ - useStore, - (producer: Producer) => setStore((state) => produce(state, producer)), - ] as const; + return [useStore, compose(setStore, immer)] as const; } -export function produce(state: T, producer: Producer) { - return producer instanceof Function ? baseProduce(state, producer) : producer; +export default function immer(next: SetStoreX) { + return (ns: NextState, opts?: object) => + next((s) => produce(s, producer(ns)), opts); } diff --git a/src/index.test.tsx b/src/index.test.tsx index 21cab22..a0412f8 100644 --- a/src/index.test.tsx +++ b/src/index.test.tsx @@ -1,11 +1,11 @@ import { it, expect, describe } from "vitest"; import { userEvent } from "@vitest/browser/context"; import { render, screen } from "@testing-library/react"; -import create, { useEqual } from "."; +import create from "."; import { renderToString } from "react-dom/server"; it("create", async () => { - const [useVal, setVal, getVal] = create(false); + const [useVal, setVal] = create(false); function A() { return ; @@ -26,7 +26,6 @@ it("create", async () => { expect(screen.queryByText("OK")).toBeNull(); await userEvent.click(screen.getByText("Click")); expect(screen.getByText("OK")).not.toBeNull(); - expect(getVal()).toBe(true); }); it("multiple listeners", async () => { @@ -78,29 +77,6 @@ it("selector", async () => { expect(screen.getByText("OK")).not.toBeNull(); }); -it("selector with equal", async () => { - const [useVal, setVal] = create({ val: "test" }); - - let count = 0; - - function A() { - count++; - const [{ val }] = useVal( - useEqual( - (s) => [s], - ([a], [b]) => a.val === b.val - ) - ); - return ; - } - - render(); - - await userEvent.click(screen.getByText("test")); - - expect(count).toBe(1); -}); - describe("server component", async () => { it("basic", () => { const [useVal] = create(1); diff --git a/src/index.ts b/src/index.ts index 3c71e58..ad4a992 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,32 +1,45 @@ -import { SetStateAction, useRef, useSyncExternalStore } from "react"; +import { useSyncExternalStore } from "react"; -export type Equal

= (a: P, b: P) => boolean; - -export type Selector = (val: T, serverSide: boolean) => P; +/** + * A function to select a part of the state to return. + */ +export type Selector = (state: S, serverSide: boolean) => R; /** - * A hook to use the state. + * A React hook to use the state. */ -export type UseStore = { +export type UseStore = { /** * @returns The current state. */ - (): T; + (): S; /** - * @param selector A function to select a part of the state to return. * @returns The selected part of the state. */ -

(selector: Selector): P; + (selector: Selector): R; }; /** - * A hook to create a global state that can be used across components. + * A function to produce the next state based on the previous state. + */ +export type Produce = (previous: S) => S | void; + +/** + * A state or a function to get next state. + */ +export type NextState = S | Produce; + +/** + * A function to update the state. It can be used outside the React components. + */ +export type SetStore = (nextState: NextState) => void; + +/** + * Creates a global store for cross-component state management. * @param init The initial value of the state. - * @returns A React hook to use the state, a function to update the state, and a function to get the state. - * If you want a component to rerender when the state changes, you can use the hook. */ -export default function create(init: T) { +export default function create(init: S) { type Listener = () => void; let state = init; @@ -39,16 +52,16 @@ export default function create(init: T) { }; }; - const useStore: UseStore =

( - selector: Selector = (val: T) => val as unknown as P + const useStore: UseStore =

( + selector: Selector = (val: S) => val as unknown as P ) => { const get = (serverSide: boolean) => () => selector(state, serverSide); - return useSyncExternalStore(subscribe, get(false), get(true)); + return useSyncExternalStore(subscribe, get(false), get(true)); }; - const setStore = (act: SetStateAction) => { + const setStore: SetStore = (ns: NextState) => { // update val with the new value - state = act instanceof Function ? act(state) : act; + state = producer(ns)(state) as S; // notify all listeners for (const listener of listeners) { @@ -56,27 +69,13 @@ export default function create(init: T) { } }; - return [useStore, setStore, () => state] as const; + return [useStore, setStore] as const; } /** - * - * @param selector Same as the selector in useStore. - * @param equal If equal returns true, the previous selected value will be returned, - * else the current selected value will be returned. + * Converts a NextState to a Produce function. * @returns */ -export function useEqual( - selector: Selector, - equal: Equal

-): Selector { - const prev = useRef

(); - - return (val, serverSide) => { - const selected = selector(val, serverSide); - return (prev.current = - prev.current !== undefined && equal(prev.current, selected) - ? prev.current - : selected); - }; +export function producer(ns: NextState): Produce { + return ns instanceof Function ? ns : () => ns; } diff --git a/src/persistent.test.tsx b/src/persistent.test.tsx index b402483..a991584 100644 --- a/src/persistent.test.tsx +++ b/src/persistent.test.tsx @@ -3,6 +3,7 @@ import { cleanup, render, screen } from "@testing-library/react"; import createPersistentStorage, { createLocalStorage, defaultKey, + saveHistory, } from "./persistent"; describe("URLStorage", () => { @@ -17,7 +18,7 @@ describe("URLStorage", () => { }); it("basic", () => { - const [useVal, setVal, , getVal] = createPersistentStorage("01"); + const [useVal, setVal] = createPersistentStorage("01"); function A() { return

{useVal()}
; @@ -26,7 +27,6 @@ describe("URLStorage", () => { render(
); expect(screen.getByText("01")).not.toBeNull(); - expect(getVal()).toBe("01"); cleanup(); @@ -58,7 +58,7 @@ describe("URLStorage", () => { return
{useVal()}
; } - setVal(() => "02", true); + setVal(() => "02", { [saveHistory]: true }); render(
); @@ -102,7 +102,7 @@ describe("localStorage", () => { }); it("basic", () => { - const [useVal, setVal, getVal] = createLocalStorage("01"); + const [useVal, setVal] = createLocalStorage("01"); function A() { return
{useVal()}
; @@ -111,7 +111,6 @@ describe("localStorage", () => { render(
); expect(screen.getByText("01")).not.toBeNull(); - expect(getVal()).toBe("01"); cleanup(); diff --git a/src/persistent.ts b/src/persistent.ts index 305f739..07e34be 100644 --- a/src/persistent.ts +++ b/src/persistent.ts @@ -1,81 +1,79 @@ -import createState from "."; -import { type Producer, produce } from "./immer"; +import create, { producer } from "."; +import { compose, SetStoreX } from "./utils"; +import immer from "./immer"; export const defaultKey = "global-state"; -/** - * A hook to create a global state that is persisted in the url hash, it uses immer for state mutation. - * @param key The key to use in the url hash. - * @param init The initial value of the state. - * @returns A hook to use the state, a function to update the state, a function to set the state by current url hash, - * and a function to get the state. - */ -export default function createURLStorage(init: T, key = defaultKey) { - const storage = new URLStorage(key); - const getVal = () => storageGetter(storage, init); - const [useStore, baseSetStore, getStore] = createState(getVal()); +export default function createPersistentStorage(init: T, key = defaultKey) { + const storage = new URLStorage(key, init); + const [useStore, setStore] = create(storage.get()); + const setByStorage = () => setStore(storage.get()); + return [ + useStore, + compose(setStore, immer, urlStorage(storage)), + setByStorage, + ] as const; +} - const setStore = (producer: Producer, saveHistory: boolean = false) => { - baseSetStore((val) => { - val = produce(val, producer); - storage.set(val, saveHistory); +export function createLocalStorage(init: T, key = defaultKey) { + const storage = new LocalStorage(key, init); + const [useStore, setStore] = create(storage.get()); + return [useStore, compose(setStore, immer, localStorage(storage))] as const; +} - return val; - }); - }; +export const saveHistory = Symbol("saveHistory"); - const setByStorage = () => baseSetStore(getVal); +export type URLStorageOptions = { [saveHistory]?: boolean }; - return [useStore, setStore, setByStorage, getStore] as const; +export function urlStorage(storage: URLStorage) { + return function (next: SetStoreX): SetStoreX { + return (ns, opts?: URLStorageOptions) => { + const produce = producer(ns); + next((state) => { + const s = produce(state) as S; + storage.set(s, opts?.[saveHistory]); + return s; + }, opts); + }; + }; } -/** - * A function to create a global state that is persisted with the provided storage. - * @param storage The storage to use. - * @param init The initial value of the state. - * @returns A hook to use the state, a function to update the state, and a function to get the state. - */ -export function createLocalStorage(init: T, key = defaultKey) { - const storage = new LocalStorage(key); - const [useStore, baseSetStore, getStore] = createState( - storageGetter(storage, init) - ); - - const setStore = (producer: Producer) => { - baseSetStore((val) => { - val = produce(val, producer); - storage.set(val); - return val; - }); +export function localStorage(storage: LocalStorage) { + return function (next: SetStoreX): SetStoreX { + return (ns, opts) => { + const produce = producer(ns); + next((state) => { + storage.set(state); + return produce(state); + }, opts); + }; }; - - return [useStore, setStore, getStore] as const; } -class LocalStorage { - constructor(private key: string) {} +export class LocalStorage { + constructor(private key: string, private init: S) {} get() { - const item = localStorage.getItem(this.key); - return item === null ? undefined : (JSON.parse(item) as T); + const item = window.localStorage.getItem(this.key); + return item === null ? this.init : (JSON.parse(item) as S); } - set(val: T) { - localStorage.setItem(this.key, JSON.stringify(val)); + set(val: S) { + window.localStorage.setItem(this.key, JSON.stringify(val)); } } -class URLStorage { - constructor(private key: string) {} +export class URLStorage { + constructor(private key: string, private init: S) {} get() { const hash = location.hash.substring(1); const params = new URLSearchParams(hash); const item = params.get(this.key); - return item === null ? undefined : (JSON.parse(item) as T); + return item === null ? this.init : (JSON.parse(item) as S); } - set(val: T, saveHistory?: boolean) { + set(val: S, saveHistory?: boolean) { const hash = location.hash.substring(1); const params = new URLSearchParams(hash); params.set(this.key, JSON.stringify(val)); @@ -89,8 +87,3 @@ class URLStorage { } } } - -function storageGetter(storage: { get: () => T | undefined }, init: T) { - const item = storage.get(); - return item === undefined ? init : item; -} diff --git a/src/utils.test.tsx b/src/utils.test.tsx new file mode 100644 index 0000000..eaed44f --- /dev/null +++ b/src/utils.test.tsx @@ -0,0 +1,51 @@ +import { it, expect } from "vitest"; +import { userEvent } from "@vitest/browser/context"; +import { render, screen } from "@testing-library/react"; +import create, { producer, SetStore } from "."; +import { compose, Middleware, useEqual } from "./utils"; + +it("selector with equal", async () => { + const [useVal, setVal] = create({ val: "test" }); + + let count = 0; + + function A() { + count++; + const [{ val }] = useVal( + useEqual( + (s) => [s], + ([a], [b]) => a.val === b.val + ) + ); + return ; + } + + render(); + + await userEvent.click(screen.getByText("test")); + + expect(count).toBe(1); +}); + +it("compose", async () => { + let state = 0; + const set: SetStore = (ns) => { + state = producer(ns)(state)!; + }; + + const addOne: Middleware = (next) => (ns) => { + next((s) => { + return producer(ns)(s)! + 1; + }); + }; + + const double: Middleware = (next) => (ns) => { + next((s) => { + return producer(ns)(s)! * 2; + }); + }; + + compose(set, addOne, double)(2); + + expect(state).toBe(6); +}); diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..d5d0926 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,47 @@ +import { useRef } from "react"; +import { NextState, type Selector } from "."; + +export type Equal = (a: R, b: R) => boolean; + +/** + * + * @param selector Same as the selector in useStore. + * @param equal If equal returns true, the previous selected value will be returned, + * else the current selected value will be returned. + * @returns + */ +export function useEqual( + selector: Selector, + equal: Equal +): Selector { + const prev = useRef(); + + return (val, serverSide) => { + const selected = selector(val, serverSide); + return (prev.current = + prev.current !== undefined && equal(prev.current, selected) + ? prev.current + : selected); + }; +} + +/** + * A function to update the state with options. + */ + +export type SetStoreX = (nextState: NextState, options?: object) => void; + +/** + * A middleware to update the state with options. + */ +export type Middleware = (next: SetStoreX) => SetStoreX; + +/** + * Composes multiple middlewares. + */ +export function compose( + setStore: SetStoreX, + ...middlewares: Middleware[] +) { + return middlewares.reduceRight((s, middleware) => middleware(s), setStore); +}