diff --git a/README.md b/README.md index bac0b18..a4e144c 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ const UserNameInput = () => { ``` Handling arrays can be done similarly, -and in this case, we also add the custom function `complete` to the store. +and in this case, we also add the custom function `complete` to the store: ```tsx import storeHook from "tyin/hook"; @@ -115,7 +115,6 @@ const useTodoList = extend( // (Optional) Remove the `with` (and `seal`) method: .seal(); - const TodoApp = () => { const todos = useTodoList((todos) => todos.filter((todo) => !todo.completed)); @@ -123,7 +122,7 @@ const TodoApp = () => { <> useTodos.complete(index)} + onComplete={(index) => useTodoList.complete(index)} /> useTodoList.push({ task, completed: false })} /> @@ -160,9 +159,9 @@ The reason for this is that I have started to call `setState` directly on the ho useTourState.setState({ started: true }); ``` -This may look awkward or even _wrong_ to some; not only because `useTourState` is a function in itself, -but also because `useTourState.setState` is both global _and_ static—it lives "outside of react", -so how can it even make the hook re-render? +This may look awkward or even _wrong_ to some; not only because `useTourState` is a function itself, +but also because `useTourState.setState` is like… super global—it lives "outside of react"… +how can it even make the hook re-render? It all works thanks to [the `useSyncExternalStore` hook](https://react.dev/reference/react/useSyncExternalStore) that ships with React. With it, you can make virtually anything re-render, and it is what drives both zustand and Tyin. @@ -171,11 +170,11 @@ Zustand popularized the idea that "the hook _is_ the store", and this project ev The key difference is that in Tyin, you put your state update functions on _the store_ instead of _the state_. This separates your data from your code ([which is generally considered good practice](https://wiki.c2.com/?SeparationOfDataAndCode)). -If you can look beyond that initial _irk_, you may start seeing some benefits with using this pattern. -Remember: you can now access and update the store from anywhere, and your components will comply—magic! 🪄 +If you can look beyond _"that initial irk"_, you may start seeing some benefits with using this pattern. +Remember: you can now access and update the store from anywhere, and your components will simply comply—magic! 🪄 Another pain point I had with using zustand "the vanilla way" was that I kept declaring -the same couple of state update functions over and over again for each store. +the same couple of state setter over and over again for each store. This is what finally drove me to just call `setState` directly instead since it's versatile enough for most use cases: ```ts @@ -204,19 +203,19 @@ feature-complete state management solution in just a few bytes! ### 1. Modularity -Tyin doesn't come with an entry point—that's intentional! +Tyin doesn't come with a single entry point—that's intentional! It instead ships a couple of highly stand-alone modules, so that the user can import only the functionality that they need. ### 2. Genericism -Tyin exposes generic APIs to maximize ergonomics and minimize footprint. +Tyin exposes generic APIs that aim to maximize ergonomics and bundle size. Generic APIs facilitate code reuse, leading to synergies in consuming applications. For example: There is no `ObjectAPI.setKey(key, value)` function, because `ObjectAPI.patch({ [key]: value })` covers that need -and a lot of other needs with a more generic API. +and a lot of other needs, simply by providing a generic API. This API is powerful enough to receive aggressive reuse in the consuming app; leading to an even smaller bundle size overall. ### 3. Composability @@ -236,22 +235,38 @@ bun run test/bundle-size/estimate.ts This is the current output: ```txt -extend.js: 182B (146B gzipped) -hook.js: 529B (352B gzipped) -plugin-array.js: 332B (187B gzipped) -plugin-object.js: 286B (228B gzipped) -plugin-persist.js: 415B (307B gzipped) -store.js: 245B (211B gzipped) +extend.js: 167B (140B gzipped) +hook.js: 529B (349B gzipped) +plugin-array.js: 332B (191B gzipped) +plugin-object.js: 286B (226B gzipped) +plugin-persist.js: 415B (304B gzipped) +store.js: 245B (212B gzipped) ``` -So, that means if you only import `tyin/hook`; Tyin will add 529 bytes to your bundle size (or ~352 gzipped). +So, that means if you only import `tyin/hook`; Tyin will add 529 bytes to your bundle size (or ~349 gzipped). Here are a few other common scenarios: -1. `tyin/hook + extend + plugin-object` = 997 bytes (716 gzipped) -2. `tyin/hook + extend + plugin-object + plugin-persist` = 1412 bytes (1034 gzipped) -3. `tyin/*` = 1736 bytes (1219 gzipped) +1. `tyin/hook + extend + plugin-object` = ~1000 bytes (~700 gzipped) +2. `tyin/hook + extend + plugin-object + plugin-persist` = ~1400 bytes (~1000 gzipped) +3. `tyin/*` = ~1750 bytes (~1200 gzipped) > **Note:** All these numbers are approximate. > Exact bundle size will vary depending on the bundler and configuration. > Gzipped size is often smaller in a real-life scenario. + +## Framework comparison + +This table compares the "general usage" between Tyin, Zustand and Redux. +I picked these frameworks, because I think most people are familiar with them. + +| | Store setup | Get state | Set state | +|-------------|--------------------------------------------------------------------------|-----------------|--------------------------------------------------| +| **Tyin** | Create store, add plugins \* | Use store hook | Call functions on the store | +| **Zustand** | Create store, define setter functions on the state \*\* | Use store hook | Call defined setter functions on the state | +| **Redux** | Create store, define setter actions, add provider to app | Use useDispatch | Dispatch defined setter actions with useDispatch | + +> **\*** = It is unusual to have to define your own setter functions on the store when using Tyin. +These are provided by plugins such as `tyin/plugin-object` instead. + +> **\*\*** This is technically not needed, [but it is the recommended usage](https://docs.pmnd.rs/zustand/getting-started/introduction). diff --git a/src/debounce.ts b/src/debounce.ts index b2e329c..0a61747 100644 --- a/src/debounce.ts +++ b/src/debounce.ts @@ -1,3 +1,10 @@ +/** + * Calls the function once the specified delay has passed since the last call. + * @param delay The delay in milliseconds. + * If the delay is zero (or less), the function will be called immediately + * without a `setTimeout`. + * @param fn The function to debounce. Return values are ignored. + */ export default function debounce void>( delay: number, fn: T diff --git a/src/extend.ts b/src/extend.ts index 2088a72..da1f6e3 100644 --- a/src/extend.ts +++ b/src/extend.ts @@ -1,14 +1,18 @@ /** - * A function that receives a value and returns an object - * with additional methods that should be assigned to it. + * A function that receives an object, + * and returns an object with additional + * properties that should be added to that object. * - * @param host The value to extend. + * @param host The object to extend. */ -export type Plugin = (host: H) => P; +export type Plugin = (host: T) => P; -/** An object that can be extended with additional methods. */ +/** An object that can be extended through plugins. */ export type Extensible = T & { - /** Extends the object with additional methods. */ + /** + * Returns a copy of this extensible object with the properties from the plugin added. + * @param plugin A function that receives the object and returns additional properties. + */ with:

(plugin: Plugin) => Extensible; /** * Removes the `with` (and `seal`) method, @@ -20,26 +24,31 @@ export type Extensible = T & { }; /** Removes the `with` and `seal` methods. */ -export type Sealed = H extends Extensible ? T : H; +export type Sealed = T extends Extensible ? T : T; /** - * Removes the `with` and `seal` methods, - * and makes the object immutable. + * Removes the `with` and `seal` methods from the host object. * * Note: The host object is mutated. * @param host The object to seal. */ -function sealExtensible(host: H): Sealed { +function sealExtensible(host: T): Sealed { delete (host as any).with; delete (host as any).seal; - return Object.freeze(host) as Sealed; + return host as Sealed; } /** - * Adds the `with` method to the object, - * which allows it to be extended with additional plugins. - * @param host The object to extend. + * Returns a new object that can be extended through plugins. + * @param host The object to make extensible. + * @example + * ```ts + * extend({ a: 1 }) // { a: 1, with: ..., seal: ... } + * .with({ b: 2 }) // { a: 1, b: 2, with: ..., seal: ... } + * .with({ b: 3 }) // { a: 1, b: 3, with: ..., seal: ... } + * .seal(); // { a: 1, b: 3 } + * ``` */ export default function extend(host: T): Extensible { const add =

(plugin: Plugin) => diff --git a/src/hook.ts b/src/hook.ts index 13bd4d9..f5e7e35 100644 --- a/src/hook.ts +++ b/src/hook.ts @@ -9,21 +9,25 @@ import createStore, { /** A function that returns a value from a state. */ export type StateSelector = (state: T) => U; -/** Returns the current state, or selects a value from it. */ +/** A function that returns the current state, or selects a value from it. */ export type StateSelectorHook = { /** Returns the current state. */ (): T; /** * Returns a value from the state. * @param select A function that returns a value from the state. - * @param equals (Optional) A function that compares equality of two selected values. + * @param equals (Optional) Compares equality of the previously selected and next value. * If the values are equal, the hook will not re-render. * The default is `Object.is`. */ (select: StateSelector, equals?: StateComparer): U; }; -/** A hook that is also a state container, a.k.a. store. */ +/** + * A hook that reacts to state changes within a store, + * combined with a `StoreAPI` object that allows you to update the state. + * @template T The type of the state. + */ export type StoreHook = StateSelectorHook & StoreAPI; function bindHook( @@ -53,15 +57,33 @@ function bindHook( } /** - * Creates a store and returns a hook for accessing it. - * @param initial The initial state. - * @param options (Optional) Options for the store. + * Creates a hook that reacts to state changes within a store. + * The returned value is both a function and a `StoreAPI` object, + * which means that you call `set` directly on the hook to update the state. + * + * **Tip:** Add new setter functions by using `tyin/extend` and plugins + * such as `tyin/plugin-object` or `tyin/plugin-array`. + * They provide a convenient API that promotes reuse, + * which helps with reducing your overall bundle size! + * @param initialState The initial state: can be an object, array, or primitive. + * @param options (Optional) Configure the default behavior of the store. + * @template T The type of the state. + * @example + * ```ts + * import storeHook from "tyin/hook"; + * import extend from "tyin/extend"; + * import objectAPI from "tyin/plugin-object"; + * + * const useExample = extend(storeHook({ a: 1, b: 2 })) + * .with(objectAPI()) + * .seal(); + * ``` */ export default function storeHook( - initial: T, + initialState: T, options?: StoreOptions ): StoreHook { - const store = createStore(initial, options); + const store = createStore(initialState, options); const hook = bindHook(store); return Object.assign(hook, store); diff --git a/src/plugin-array.ts b/src/plugin-array.ts index 36fe2c7..01ed0fb 100644 --- a/src/plugin-array.ts +++ b/src/plugin-array.ts @@ -30,7 +30,18 @@ export type ArrayAPIPlugin = Plugin< >; /** - * A plugin that adds array methods to the store. + * Adds convenience methods for working with arrays. + * @template T The type of the state, must be an array type. + * @example + * ```ts + * import storeHook from "tyin/hook"; + * import extend from "tyin/extend"; + * import arrayAPI from "tyin/plugin-array"; + * + * const useExample = extend(storeHook([1, 2, 3])) + * .with(arrayAPI()) + * .seal(); + * ``` */ const arrayAPI = (): ArrayAPIPlugin => diff --git a/src/plugin-object.ts b/src/plugin-object.ts index d000c8c..db3e105 100644 --- a/src/plugin-object.ts +++ b/src/plugin-object.ts @@ -22,13 +22,24 @@ export type PartialUpdate = | Partial | Setter>; +/** + * Convenience methods for working with objects, + * most notably `patch`, which can be used to update the state + * in many ways. + */ export type ObjectAPI = { /** Returns the number of keys in the object. */ size: () => number; /** - * Patches the given update onto the state. - * - * @param update The update to apply. + * Applies a partial update to the state. + * @param update The partial update to apply. + * @param merge (Optional) A function that merges the current state with the update. + * Defaults to the `merge` option passed when creating the plugin. + * @example + * ```ts + * useExample.patch({ a: 2 }); + * useExample.patch((state) => ({ a: state.a + 1 })); + * ``` */ patch: (update: PartialUpdate, merge?: MergeState) => void; /** Removes the given key from the object. */ @@ -47,7 +58,18 @@ const mergeLeft = >( /** * A plugin that adds object methods to the store. - * @param options Options for the plugin. + * @param options (Optional) Options for the plugin. + * @template T The type of the state, must be an object type. + * @example + * ```ts + * import storeHook from "tyin/hook"; + * import extend from "tyin/extend"; + * import objectAPI from "tyin/plugin-object"; + * + * const useExample = extend(storeHook({ a: 1, b: 2 })) + * .with(objectAPI()) + * .seal(); + * ``` */ const objectAPI = ( diff --git a/src/plugin-persist.ts b/src/plugin-persist.ts index 4e3cd49..f80a487 100644 --- a/src/plugin-persist.ts +++ b/src/plugin-persist.ts @@ -11,7 +11,7 @@ export type StorageLike = { setItem: (key: string, value: string) => void; }; -/** Options for the persist plugin.. */ +/** Options for the persist plugin. */ export type PersistOptions = { /** The key to use when storing the state. */ name: string; @@ -23,11 +23,11 @@ export type PersistOptions = { * @default 0 */ delay?: number; - /** Mutate the value before saving and/or after loading. */ + /** Modify the value before saving and/or after loading. */ map?: (state: T) => T; /** Determine whether to save the state. */ filter?: (state: T) => boolean; - /** The storage to use, defaults to localStorage. */ + /** The storage to use, defaults to localStorage in the browser. */ storage?: StorageLike; }; @@ -36,9 +36,20 @@ export type PersistPlugin = Plugin>; const localStorage = typeof window !== "undefined" ? window.localStorage : null; /** - * A plugin that persists the state to a storage of your choice, - * or localStorage by default. - * @param options Options for the plugin. + * A plugin that persists the state of the store on changes + * to a storage of your choice, or localStorage by default. + * @param options Configure the plugin. + * @template T The type of the state. + * @example + * ```ts + * import storeHook from "tyin/hook"; + * import extend from "tyin/extend"; + * import persist from "tyin/plugin-persist"; + * + * const useExample = extend(storeHook({ a: 1, b: 2 })) + * .with(persist({ name: "Example" })) + * .seal(); + * ``` */ const persist = ( options: PersistOptions diff --git a/src/store.ts b/src/store.ts index bbd30ce..ae3c3d2 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,3 +1,4 @@ +/** The supported state types. */ export type AnyState = | string | number @@ -10,22 +11,22 @@ export type AnyState = /** A function that computes the next state. */ export type Setter = (oldState: T) => U; /** A value or a function that computes the next state. */ -export type Settable = Setter | T; -/** A function that subscribes to state changes. */ +export type Settable = T | Setter; +/** A function that is called when the state changes. */ export type ChangeSubscriber = (oldState: T, newState: T) => void; -/** A function that compares equality of two states. */ +/** A function that compares the equality of the old and new state.*/ export type StateComparer = (oldState: T, newState: T) => boolean; -/** A store that holds a value and notifies subscribers when it changes. */ +/** A store that notifies its subscribers when the state changes. */ export type StoreAPI = { /** Gets the current state. */ get: () => T; /** - * Sets the next state and notifies subscribers. + * Sets the next state and notifies subscribers of the update. * @param next The next state or a function that computes the next state. - * @param equals (Optional) A function that compares equality of two states. - * If the states are equal, the state will not be updated and subscribers will not be notified. - * The default is `Object.is`. + * @param equals (Optional) Compares the equality of the old and new state before the state update. + * Updates that are deemed equal are ignored. + * Defaults to the `equals` option passed when creating the store. */ set: (next: Settable, equals?: StateComparer) => void; /** @@ -36,20 +37,31 @@ export type StoreAPI = { subscribe: (subscriber: ChangeSubscriber) => () => void; }; -/** Options for the store. */ +/** Defines the default behavior of the store. */ export type StoreOptions = { /** - * Compares equality of two states, if the states are equal, - * the store will not be updated and subscribers will not be notified. + * The default equality comparer for the store. + * * The default is `Object.is`. */ equals?: StateComparer; }; /** - * A store that holds a state and notifies subscribers of state changes. - * @param initialState The initial state. - * @param options Options for the store. + * Creates a store that notifies its subscribers when the state changes. + * @param initialState The initial state: can be an object, array, or primitive. + * @param options (Optional) Configure the default behavior of the store. + * @template T The type of the state. + * @example + * ```ts + * import createStore from "tyin/store"; + * import extend from "tyin/extend"; + * import objectAPI from "tyin/plugin-object"; + * + * const exampleStore = extend(createStore({ a: 1, b: 2 })) + * .with(objectAPI()) + * .seal(); + * ``` */ export default function createStore( initialState: T,