Skip to content

Commit

Permalink
Merge pull request #3 from mausworks/release/1.0.1
Browse files Browse the repository at this point in the history
Release/1.0.1
  • Loading branch information
mausworks authored Dec 1, 2023
2 parents 25a28ea + a437844 commit cc1a398
Show file tree
Hide file tree
Showing 8 changed files with 178 additions and 69 deletions.
59 changes: 37 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -115,15 +115,14 @@ const useTodoList = extend(
// (Optional) Remove the `with` (and `seal`) method:
.seal();


const TodoApp = () => {
const todos = useTodoList((todos) => todos.filter((todo) => !todo.completed));

return (
<>
<TodoList
todos={todos}
onComplete={(index) => useTodos.complete(index)}
onComplete={(index) => useTodoList.complete(index)}
/>
<AddTodo onSubmit={() => useTodoList.push({ task, completed: false })} />
</>
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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).
7 changes: 7 additions & 0 deletions src/debounce.ts
Original file line number Diff line number Diff line change
@@ -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<T extends (...args: any[]) => void>(
delay: number,
fn: T
Expand Down
37 changes: 23 additions & 14 deletions src/extend.ts
Original file line number Diff line number Diff line change
@@ -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<H extends object, P = void> = (host: H) => P;
export type Plugin<T extends object, P = void> = (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 object> = 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: <P>(plugin: Plugin<T, P>) => Extensible<T & P>;
/**
* Removes the `with` (and `seal`) method,
Expand All @@ -20,26 +24,31 @@ export type Extensible<T extends object> = T & {
};

/** Removes the `with` and `seal` methods. */
export type Sealed<H> = H extends Extensible<infer T> ? T : H;
export type Sealed<T> = T extends Extensible<infer T> ? 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<H>(host: H): Sealed<H> {
function sealExtensible<T>(host: T): Sealed<T> {
delete (host as any).with;
delete (host as any).seal;

return Object.freeze(host) as Sealed<H>;
return host as Sealed<T>;
}

/**
* 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<T extends object>(host: T): Extensible<T> {
const add = <P>(plugin: Plugin<T, P>) =>
Expand Down
38 changes: 30 additions & 8 deletions src/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,25 @@ import createStore, {
/** A function that returns a value from a state. */
export type StateSelector<T, U = T> = (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<T> = {
/** 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`.
*/
<U>(select: StateSelector<T, U>, equals?: StateComparer<U>): 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<T extends AnyState> = StateSelectorHook<T> & StoreAPI<T>;

function bindHook<T extends AnyState>(
Expand Down Expand Up @@ -53,15 +57,33 @@ function bindHook<T extends AnyState>(
}

/**
* 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<T extends AnyState>(
initial: T,
initialState: T,
options?: StoreOptions<T>
): StoreHook<T> {
const store = createStore(initial, options);
const store = createStore(initialState, options);
const hook = bindHook(store);

return Object.assign(hook, store);
Expand Down
13 changes: 12 additions & 1 deletion src/plugin-array.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,18 @@ export type ArrayAPIPlugin<T extends any[] | null> = 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 =
<T extends any[] | null>(): ArrayAPIPlugin<T> =>
Expand Down
30 changes: 26 additions & 4 deletions src/plugin-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,24 @@ export type PartialUpdate<T extends ObjectLike | null> =
| Partial<T>
| Setter<T, Partial<T>>;

/**
* Convenience methods for working with objects,
* most notably `patch`, which can be used to update the state
* in many ways.
*/
export type ObjectAPI<T extends ObjectLike | null> = {
/** 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<T>, merge?: MergeState<T>) => void;
/** Removes the given key from the object. */
Expand All @@ -47,7 +58,18 @@ const mergeLeft = <T extends ObjectLike | null, U = Partial<T>>(

/**
* 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 =
<T extends ObjectLike | null>(
Expand Down
23 changes: 17 additions & 6 deletions src/plugin-persist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = {
/** The key to use when storing the state. */
name: string;
Expand All @@ -23,11 +23,11 @@ export type PersistOptions<T> = {
* @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;
};

Expand All @@ -36,9 +36,20 @@ export type PersistPlugin<T extends AnyState> = Plugin<StoreAPI<T>>;
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 = <T extends AnyState>(
options: PersistOptions<T>
Expand Down
Loading

0 comments on commit cc1a398

Please sign in to comment.