diff --git a/.eslintrc.json b/.eslintrc.json index bee3c28..c40e130 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -96,6 +96,9 @@ ["^delta-state$", "./src/index.ts"], ["delta-state", "./src"] ] + }, + "typescript": { + "project": "./" } } }, diff --git a/README.md b/README.md index 09fe0ba..0911aed 100644 --- a/README.md +++ b/README.md @@ -216,43 +216,20 @@ And like that we've create and consumed our first Delta store! Using a store to manage a single bit of state naturally seems a bit clunky and cumbersome. This is where Delta's other state management mechanism - atoms - comes in handy. -Unlike full-blown stores, atoms are designed specifically to handle smaller, focused bits of state. Delta offers two ways to instantiate them - via `atom()` and via the `useAtom()` hook. Let's start with the former: +Unlike full-blown stores, atoms are designed specifically to handle smaller, focused bits of state. Delta offers two ways to instantiate them - via the `useAtom()` hook: ```tsx // app.tsx - -import { atom } from 'delta-state' - -const useTrainerAtom = atom((set) => [ - 'Ash', - (updatedName: string) => set(updatedName) -]); - -const TrainerAboutPage = () => { - ... -} -``` -
- -Like a store, we call the atom method, pass a type or interface specifying what the type of state we're providing is, pass a function specifying the initial state, and return a custom React hook to consume in a component. Unlike a store, we only need to pass the type of data specific to the atom then specify an initial value and an action to update the atom's state. - -This condensed API is what makes atoms unique - like React's `useState()` all information relevant to the given piece of state are effectively specified inline. Unlike `useState()`, we get to define how that state is updated right in the declaration. This means no matter where an atom is consumed, the mechanism for manipulating its state remains as consistent as possible. - -Continuing below, we call our custom `useTrainerAtom()` hook inside our component, passing a selector like we did when we called our custom store hook: - - -```tsx -// app.tsx -... +import { useAtom } from 'delta-state' const TrainerAboutPage = () => { - const [name, updatedName] = useTrainerAtom((state) => state); + const trainerName = useAtom('Ash'); return (
-

Hello, my name is {name}

+

Hello, my name is {trainerName.get()}

@@ -260,7 +237,7 @@ const TrainerAboutPage = () => { updateName(e.target.value)} + onChange={(e) => trainerName.set(e.target.value)} />
@@ -268,18 +245,12 @@ const TrainerAboutPage = () => { ); } ``` -
- -> Why Selectors with Atoms? 💡 -> -> While atoms are designed to handle discrete pieces of state, sometimes these discrete pieces may take the form of arrays, objects, or other more complex data. By requiring use of selectors with atoms like we do with stores, we make dealing with complex data easier and achieve a more consistent API. - -
-Like `useState()`, our call to our atom hook returns a two-element array, with the first item being the state value and the second being the action to update that state. +The `useAtom()` hook returns an atom object, which we can then call `get()` and `set()` on to access and update state. -Finally let's examine the `useAtom()` hook: +This condensed API is what makes atoms unique - like React's `useState()` all information relevant to the given piece of state are effectively specified inline. Unlike `useState()`, the methods to manipulate and retrieve state are owned by the atom, so keeping track of where changes occur and state is being accessed is easier. +We can do a lot more than just passing values to atoms - `get()`, `set()` and calls to `useAtom()` also accept functions with a helpful (optional) `get()` arg to extract values from other atoms: ```tsx // app.tsx @@ -287,15 +258,13 @@ import { useAtom } from 'delta-state' const TrainerAboutPage = () => { - const [name, updatedName] = useAtom( - 'Ash', - (set) => (updatedName: string) => set(updatedName) - ); + const lastName = useAtom('Pikachu'); + const trainerName = useAtom((get) => `Ash ${get(lastName)}`); return (
-

Hello, my name is {name}

+

Hello, my name is {trainerName.get((get) => `${get(trainerName)}!`)}

@@ -303,7 +272,7 @@ const TrainerAboutPage = () => { updateName(e.target.value)} + onChange={(e) => trainerName.set(() => e.target.value)} />
@@ -311,15 +280,44 @@ const TrainerAboutPage = () => { ); } ``` -
-This achieves exactly what the above example does, but localizes the state to the given component. +This allows you to compose atoms while keeping individual atom state pure. -> Atom vs useAtom? 💡 -> -> In general, we recommend using `atom()` when you need to share a discrete piece of state between multiple components and `useAtom()` when that state needs to be local to the component. +Atoms also allow you to subscribe to state updates, via `subscribe()`: -
+```tsx +import { useAtom } from 'delta-state' + +export default function CounterApp() { + + const atom = useAtom(0); + const atomTwo = useAtom('even'); + + // Our subscription will trigger every time a state update + // for our first atom occurs, which we can then use to set + // the state of our second atom. + atom.subscribe((count) => { + atomTwo.set((get) => count%2 ? 'odd' : 'even') + }); + + return ( + <> +
+
+ +

+ {atom.get()} +

+
+
+ + ); +} +``` + +By default, subscriptions will trigger any time a given atom's state is updated. ---- ### Recipes 🍲 @@ -358,13 +356,7 @@ const CounterApp = () => { add: state.add })); - const [ - counter, - setCounter - ] = useAtom( - count, - (set) => (next: number) => set(next + 1) - ); + const atom = useAtom(count); return ( <> @@ -372,15 +364,15 @@ const CounterApp = () => {
-

- {counter} + {atom.get()}

@@ -389,7 +381,6 @@ const CounterApp = () => { } ``` -
Our app above uses a store and an atom created using the `useAtom()` hook to manage the same counter. Since our atom's state is created using the `useAtom()` hook from the state of the store's count value, we refer to the atom as a derived atom. We want both the "Increment Local" and "Increment Global" buttons to increase our counter. However when we press "Increase Global" nothing happens! What's the deal?! @@ -420,14 +411,7 @@ const CounterApp = () => { add: state.add })); - const [ - counter, - setCounter - ] = useAtom( - count, - (set) => (next: number) => set(next + 1), - (source, next) => next + 1 - ); + const atom = useAtom(count, (source, local) => local + 1); return ( <> @@ -435,15 +419,15 @@ const CounterApp = () => {
-

- {counter} + {atom.get()}

@@ -451,7 +435,6 @@ const CounterApp = () => { ); } ``` -
A `link()` is a function accepting two arguments - the first the source state and the second the atom's "local" state - and returns a value matching the type specified to the atom that the atom will use for its next state. Link functions allow you to reconcile the difference between the source and local state of a derived atom so that the behavior of your application remains consistent.
@@ -461,10 +444,10 @@ A `link()` is a function accepting two arguments - the first the source state an Stores aren't solely for holding application state - they can also be used to generate atoms on-the-fly! ```tsx -import { create, useAtom, DerivedAtom } from 'delta-state' +import { create, useAtom, Atomic } from 'delta-state' interface CounterStore { - counterAtom: DerivedAtom + counterAtom: Atomic }; const useCounterStore = create((set) => ({ @@ -479,23 +462,17 @@ export default function CounterApp() { useCounterAtom: state.counterAtom })); - const [ - counter, - setCounter - ] = useCounterAtom( - 0, - (set) => (next: number) => set(next + 1) - ); + const atom = useCounterAtom(0); return ( <>
-

- {counter} + {atom.get()}

@@ -504,12 +481,12 @@ export default function CounterApp() { } ``` -Delta includes the `DerivedAtom` type, which allows you to pass the useAtom hook as a store item. You can then alias and instantiate an instance of that atom wherever needed! +Delta includes the `Atomic` type, which allows you to pass the useAtom hook as a store item. You can then create an instance of that atom wherever needed!
#### Comparators and Controlling State Updates -In addition to selectors store and atom hooks you create via `atom()` can take an optional comparator function that will only allow for store or atom state to be updated if the comparator function returns `true`: +Stores and the atom's `subscribe()` method take an optional `comparator()` function that allows you to filter state updates or subscription events: ```tsx import { create } from 'delta-state' @@ -548,8 +525,13 @@ const CounterApp = () => { return ( <>
-
-

@@ -563,6 +545,43 @@ const CounterApp = () => { ``` +For atoms, comparators can be used to filter subscription events: + +```tsx +import { useAtom } from 'delta-state' + +export default function CounterApp() { + + const atom = useAtom(0); + + atom.subscribe( + (count) => { + console.log('Next odd number is: ', count); + }, + + // Our subscription will only trigger + // on odd numbers. We can also omit `prev` + // since we aren't using it. + ({ next }) => next%2 === 1 + ); + + return ( + <> +
+
+ +

+ {atom.get()} +

+
+
+ + ); +} +``` + Comparator functions accept a single object argument containing `next` and `prev` - with `next` being the requested state update and `prev` being the currnet store or atom state - and return a `boolean` (the value of the comparison performed in the function). We recommend using comparators to optimize your application's performance by controlling when state updates and React re-renders occur.
@@ -584,48 +603,72 @@ To access `get()`, just specify it as an argument in addition to `set()`!
---- -### Async Usage Without React 🤖 +### Async and Usage Without React 🤖 -If you need to use Delta in asynchronous code like React Server Components or Server Actions or independently of React, you can do so via `createAsync()`, `asyncAtom()`, `createBase()`, and `atomBase()`. +Delta supports both sync and async use without react. Import from either `delta-state/async` or `delta-state/base` to access the async and base versions of atoms. -Let's start with `createAsync()` - as before we'll create an interface to represent our store's state and actions: +Rather than calling `useAtom()`, you'll instead call `atom()` to create an `async` or `base` atom: ```ts // async.ts -import { createAsync } from "delta-state"; +import { atom } from "delta-state/base"; -interface CounterStore { - count: number; - updateCount: (next: number) => void; +const runCounter = () => { + const counterAtom = atom(0) + counterAtom.set((get) => get(counterAtom) + 1) } -const asyncCounter = async () => { - + +runCounter() +``` + +Async atoms require async functions for `atom()` and `set()`, which must be awaited: + +```ts +// async.ts + +import { atom } from "delta-state/async"; + +const runCounter = async () => { + const counterAtom = await atom(async (get) => 0) + await counterAtom.set(async (get) => get(counterAtom) + 1) } -asyncCounter() +runCounter() ``` -Next we'll instantiate an async store instace via `createAsync()`: - +Creating async and base stores follows much the same pattern as their react counterpart, with the exception that you must pass an async function and await the Promise returned by `create()` for async stores: ```ts -// async.ts +import { create } from "delta-state/async"; + +interface Store { + counter: number; + add: (amount: number) => void +} + +// This isn't just a function but a Promise! We'll need to +// await it. +const createCounterStore = create(async (set) => ({ + counter: 0, + add: (amount) => set({ + counter: amount + 1 + }) +})); + ... -const asyncCounter = async () => { +const runAsyncCounter = async () => { - // The call to createAsync() returns a Promise so + // The call to create() returns a Promise so // we'll need to await it if we want to get the // function to use our store. - const asyncStore = await createAsync( - - // We pass an async function to create our store - async (set, get) => ({ + const asyncStore = await createCounterStore( + (set, get) => ({ count: 0, updateCount: (next: number) => set({ @@ -635,21 +678,31 @@ const asyncCounter = async () => { ); } -asyncCounter() +runAsyncCounter() ``` -There are a few important differences! First, when creating async stores, the function you pass must be async. Next, you must await the Promise returned by the call to `createAsync()` -to access the function to use your store. To continue our example: +For base stores: ```ts -// async.ts +import { create } from "delta-state/base"; -... +interface Store { + counter: number; + add: (amount: number) => void +} +const createCounterStore = create((set) => ({ + counter: 0, + add: (amount) => set({ + counter: amount + 1 + }) +})); -const asyncCounter = async () => { - const asyncStore = await createAsync( - async (set, get) => ({ +... + +const runCounter = () => { + const customStore = createCounterStore( + (set, get) => ({ count: 0, updateCount: (next: number) => set({ @@ -657,25 +710,13 @@ const asyncCounter = async () => { }) }) ); - - const { - count, - updateCount, - - // By default async stores return two additional - // helper functions, get and set, which - // function exactly like calling get() and set() - // inside an action. - get, - set - } = asyncStore((state) => state); - } -asyncCounter() +runCounter() ``` -As with normal stores, we return both our value and action. However, -unlike async stores, our value does not automatically update when an action or `set()` is called. In order + +As with React stores, we return both our value and action. However, +unlike React stores, state for base and async store does not automatically update when an action or `set()` is called. In order to access the updated store value after calling an action you must call the `get()` method, which (like when calling `get()` inside an action) returns the entire store: ```ts @@ -683,10 +724,10 @@ to access the updated store value after calling an action you must call the `get ... -const asyncCounter = async () => { +const runAsyncCounter = async () => { const asyncStore = await createAsync( - async (set, get) => ({ + (set, get) => ({ count: 0, updateCount: (next: number) => set({ @@ -711,7 +752,7 @@ const asyncCounter = async () => { } -asyncCounter() +runAsyncCounter() ``` We can also update the store by calling `set()`, which works exactly as it does when you call it inside an action: @@ -721,10 +762,10 @@ We can also update the store by calling `set()`, which works exactly as it does ... -const asyncCounter = async () => { +const runAsyncCounter = async () => { const asyncStore = await createAsync( - async (set, get) => ({ + (set, get) => ({ count: 0, updateCount: (next: number) => set({ @@ -744,19 +785,19 @@ const asyncCounter = async () => { } -asyncCounter() +runAsyncCounter() ``` -If you need to access store state changes reactively (i.e. whenever an action or `set()` mutates store state), you can call `subscribe()`, which takes a callback and optional comparator function: +If you need to access store state changes reactively (i.e. whenever an action or `set()` mutates store state), you can call `subscribe()` on both async and base stores. Like `subscribe()` for atoms, the function takes a callback and optional comparator function: ```ts // async.ts ... -const asyncCounter = async () => { +const runAsyncCounter = async () => { const asyncStore = await createAsync( - async (set, get) => ({ + (set, get) => ({ count: 0, updateCount: (next: number) => set({ @@ -793,93 +834,10 @@ const asyncCounter = async () => { } -asyncCounter() +runAsyncCounter() ``` -We can likewise instantiate async atoms via `atomAsync()`: - -```ts -// async.ts - -... - -const asyncCounter = async () => { - - ... - - const counterAtom = await atomAsync(async (set, get) => [ - 0, - (next) => set(next + get()), - ]); - - const [ - value, - add, - get, - set, - subscribe - ] = counterAtom((value) => value); - -} - -asyncAtomCounter() - -``` - -As with async stores, async atoms include `get()`, `set()`, and `subscribe()`, allowing you to manage the atom's state as needed. - -If synchronous usage independent of React is required, `createBase()` and `atomBase()` allow you you to instantiate stores and atoms: - -```ts -// sync.ts -import { createBase } from 'delta-state'; - -... - -const syncCounterStore = () => { - - const syncCounter = createBase((set, get) => ({ - count: 0, - updateCount: (next: number) => - set({ - count: next + get().count, - }) - }) - ); - - const { - count, - updateCount, - get, - set, - subscribe - } = syncCounter((state) => state); - -} - -const syncCounterAtom = () => { - const counterAtom = atomAsync((set, get) => [ - 0, - (next) => set(next + get()), - ]); - - const [ - value, - add, - get, - set, - subscribe - ] = counterAtom((value) => value); -} - -syncCounterStore() -syncCounterAtom() - -``` - -The APIs for sync base stores and atoms follow those of their asynchronous counterparts minus the need for async functions and awaiting the store or atom in order to use it. - ---- ### Credits and Thanks 🙏 diff --git a/examples/async.ts b/examples/async.ts index 6da80ac..8b50b01 100644 --- a/examples/async.ts +++ b/examples/async.ts @@ -1,33 +1,21 @@ -import { createAsync } from "../src"; - -interface CounterStore { - count: number; - updateCount: (next: number) => void; - getCount: () => number; -} +import { atom } from "../src/async"; const test = async () => { - const asyncStore = await createAsync(async (set, get) => ({ - count: 0, - updateCount: (next: number) => - set({ - count: next + get().count, - }), - getCount: () => get().count, - })); - + const atomTwo = await atom(0); + const atomThree = await atom(0); - const { get, set, subscribe } = asyncStore((state) => state); + atomThree.subscribe((count) => { + atomTwo.set(count + atomTwo.get()); + console.log(count); + }); - subscribe( - ({ count }) => { - console.log(count); - }, - ({ next, prev }) => next.count > prev.count + 10 - ); + atomThree.set(1); + atomThree.set(1); + atomThree.set(1); - set({ count: get().count + 100 }); + console.log(atomThree.get()); + console.log(atomTwo.get()); }; test(); diff --git a/package.json b/package.json index acc3754..48be585 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "delta-state", - "version": "1.8.1", + "version": "1.9.0", "description": "A modern version of the Delta state manager - written for TS and with the use of React Hooks.", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -50,6 +50,7 @@ "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-import-resolver-alias": "^1.1.2", + "eslint-import-resolver-typescript": "^3.6.1", "eslint-plugin-import": "^2.29.1", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-react": "^7.33.2", @@ -62,9 +63,9 @@ "vite-plugin-dts": "^3.7.3" }, "dependencies": { - "use-sync-external-store": "^1.2.0", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "use-sync-external-store": "^1.2.0" }, "files": [ "dist" diff --git a/src/async/atom.ts b/src/async/atom.ts new file mode 100644 index 0000000..73a17fd --- /dev/null +++ b/src/async/atom.ts @@ -0,0 +1,69 @@ +import { createAtomApi, getValueFromCreator } from "~/base/atom.ts"; +import { Atom, Read } from "~/base/types.ts"; + +const getValueFromAsyncCreator = async (creator: T | Read>) => { + const getState = (atom: Atom): T => atom.get(); + const syncCreator = creator as (get: (atom: Atom) => T) => T; + + return typeof creator === "function" ? await syncCreator(getState) : creator; +}; + +const createAsyncAtom = async ( + creator: T | Read>, + comparator?: ({ next, prev }: { next: T; prev: T }) => boolean, +) => { + const createNextAtom = createAtomApi(); + const atomStore = createNextAtom({} as any); + + const getState = (atom: Atom): T => atom.get(); + + const asyncCreator = creator as (get: (atom: Atom) => T) => Promise; + + const init = + typeof creator === "function" ? await asyncCreator(getState) : creator; + atomStore.setState(init); + + const atom = { + store: atomStore, + get: (transform?: Read) => + transform ? getValueFromCreator(transform) : atomStore.getState(), + set: async (next: T | Read>) => { + const value = await getValueFromAsyncCreator(next); + const shouldUpdate = + !comparator || + comparator({ + next: value, + prev: atomStore.getState(), + }); + + if (shouldUpdate) { + atomStore.setState(value); + atomStore.subscribers.forEach((callback) => { + callback(value); + }); + } + }, + subscribe: ( + callback: (next: T) => void, + callbackComparator?: ({ next, prev }: { next: T; prev: T }) => boolean, + ) => { + if (callbackComparator) { + const currentState = atomStore.getState(); + + atomStore.subscribers.add( + (state) => + callbackComparator({ + next: state as T, + prev: currentState as any, + }) && callback(state as T), + ); + } else { + atomStore.subscribers.add(callback as (state: any) => void); + } + }, + } as Atom; + + return atom; +}; + +export const atom = createAsyncAtom; diff --git a/src/async/index.ts b/src/async/index.ts new file mode 100644 index 0000000..d88d07b --- /dev/null +++ b/src/async/index.ts @@ -0,0 +1,2 @@ +export { atom } from "./atom"; +export { create } from "./store"; diff --git a/src/async/store.ts b/src/async/store.ts new file mode 100644 index 0000000..d7f6fec --- /dev/null +++ b/src/async/store.ts @@ -0,0 +1,30 @@ +import { createInternalBaseReference, createStoreApi } from "~/base/store.ts"; +import { Listener } from "~/base/types.ts"; + +const createAsyncStoreFromState = () => { + const useCreatedStore = async ( + creator: (set: (next: Partial) => void, get: () => U) => Promise, + ) => { + const createNextStore = createStoreApi(); + const store = createNextStore({} as any); + + const setState = (next: Partial): void => { + store.subscribers.forEach((callback: Listener) => { + callback(next); + }); + }; + + const getState = (): U => store.getState(); + + const init = await creator(setState, getState); + store.setState({ + next: init, + }); + + return createInternalBaseReference(store); + }; + + return useCreatedStore; +}; + +export const create = createAsyncStoreFromState(); diff --git a/src/atom.ts b/src/atom.ts deleted file mode 100644 index 48254ff..0000000 --- a/src/atom.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { useMemo, useRef, useSyncExternalStore } from "react"; -import useSyncExports from "use-sync-external-store/shim/with-selector.js"; -import { Atom, Listener } from "./types.ts"; -import { useSyncExternalAtomWithSelectorAsync } from "./vanilla.ts"; - -const { useSyncExternalStoreWithSelector } = useSyncExports; - -export const createAtom = (atom: T): Atom => - ({ - value: atom, - subscribers: new Set>(), - subscribe(callback: Listener) { - this.subscribers.add(callback); - return () => this.subscribers.delete(callback); - }, - getState() { - return this.value; - }, - getInitState() { - return atom; - }, - setState(next: T) { - this.value = next; - }, - }) as Atom; - -const createAtomApi = () => createAtom; - -export const useAtom = ( - atom: T, - update: (set: (next: T) => T) => (next: T) => T | Promise, - link?: (source: T, local: T) => T, -) => { - const atomStore = useRef(createAtom(atom)).current; - const lastLinkedState = useRef(atom); - - const set = (next: T) => { - atomStore.value = next; - atomStore.subscribers.forEach((callback) => callback({})); - return next; - }; - - const setUpdate = update(set); - - useMemo(() => { - if (lastLinkedState.current !== atom && link) { - lastLinkedState.current = atom; - atomStore.value = link(lastLinkedState.current, atomStore.value); - } - }, [atom, atomStore, link]); - - return [ - useSyncExternalStore( - (callback) => atomStore.subscribe(callback), - () => atomStore.value, - () => atom, - ), - setUpdate, - ] as [T, typeof setUpdate]; -}; - -const createInternalReference = (atomStore: Atom) => { - const init = atomStore.value; - - const useCreatedStore = ( - selector: (value: T) => T, - comparator?: ({ next, prev }: { next: T; prev: T }) => boolean, - ) => { - return [ - useSyncExternalStoreWithSelector( - (callback: Listener) => atomStore.subscribe(callback), - () => atomStore.value, - () => init, - selector, - comparator - ? (a: T, b: T) => - comparator({ - next: a, - prev: b, - }) - : undefined, - ), - atomStore.update, - ] as [T, (next: T) => void]; - }; - - Object.assign(useCreatedStore, atomStore) - - return useCreatedStore; -}; - -const createStoreFromState = () => { - const useCreatedStore = ( - creator: (set: (next: U) => void, get: () => U) => [U, (next: U) => void], - ) => { - const createNextAtom = createAtomApi(); - const store = createNextAtom({} as U); - - const setState = (next: U) => { - store.value = next; - store.subscribers.forEach((callback: Listener) => callback({})); - return next; - }; - - const getState = () => store.value; - - const init = creator(setState, getState); - store.setState(init[0]); - store.update = init[1]; - - return createInternalReference(store); - }; - - return useCreatedStore; -}; - -const createInternalBaseReference = (atomStore: Atom) => { - const useCreatedStore = ( - selector: (state: T) => U, - comparator?: ({ next, prev }: { next: U; prev: U }) => boolean, - ) => { - const selection = useSyncExternalAtomWithSelectorAsync( - atomStore, - selector, - comparator - ? (a: U, b: U) => - comparator({ - next: a, - prev: b, - }) - : undefined, - ); - - const callback = (next: T) => { - const currentState = selector(atomStore.getState()); - const nextState = selector(next); - const shouldUpdate = comparator - ? comparator({ - next: currentState, - prev: nextState, - }) - : true; - - shouldUpdate && atomStore.setState(next); - }; - - const callbackWithComparator = ( - callbackComparator: ({ - next, - prev - }:{ - next: U, - prev: U - }) => boolean, - subscriptionCallback: (next: T) => void - ) => { - const currentState = atomStore.getState() - - atomStore.subscribers.add((state) => callbackComparator({ - next: state as U, - prev: currentState as any - }) && subscriptionCallback(state as T)); - } - - return [ - selection, - atomStore.update, - () => { - atomStore.subscribe(callback as Listener>); - return selector(atomStore.getState()); - }, - (next: T) => { - atomStore.subscribers.forEach((callback) => { - callback(next); - }); - }, - ( - callback: (next: T) => void, - callbackComparator?: ({ - next, - prev - }:{ - next: U, - prev: U - }) => boolean - ) => { - - callbackComparator ? callbackWithComparator( - callbackComparator, - callback - ) : atomStore.subscribers.add( - callback as (state: any) => void - ); - - }, - ] as [ - U, - (next: T) => void, - () => U, - (next: T) => void, - ( - callback: (next: T) => void, - comparator?: ({ next, prev }: { next: U; prev: U }) => boolean, - ) => void, - ]; - }; - - Object.assign(useCreatedStore, atomStore) - - return useCreatedStore; -}; - -const createAsyncAtomFromState = () => { - const useCreatedStore = async ( - creator: ( - set: (next: U) => void, - get: () => U, - ) => Promise<[U, (next: U) => void]>, - ) => { - const createNextAtom = createAtomApi(); - const atomStore = createNextAtom({} as any); - - const setState = (next: Partial): void => { - atomStore.subscribers.forEach((callback) => { - callback(next); - }); - }; - - const getState = (): U => atomStore.getState(); - - const init = await creator(setState, getState); - atomStore.setState(init[0]); - atomStore.update = init[1]; - - return createInternalBaseReference(atomStore); - }; - - return useCreatedStore; -}; - - -const createBaseAtomFromState = () => { - const useCreatedStore = ( - creator: ( - set: (next: U) => void, - get: () => U, - ) => [U, (next: U) => void], - ) => { - const createNextAtom = createAtomApi(); - const atomStore = createNextAtom({} as any); - - const setState = (next: Partial): void => { - atomStore.subscribers.forEach((callback) => { - callback(next); - }); - }; - - const getState = (): U => atomStore.getState(); - - const init = creator(setState, getState); - atomStore.setState(init[0]); - atomStore.update = init[1]; - - return createInternalBaseReference(atomStore); - }; - - return useCreatedStore; -}; - -export const atom = createStoreFromState(); -export const atomAsync = createAsyncAtomFromState(); -export const atomBase = createBaseAtomFromState() diff --git a/src/base/atom.ts b/src/base/atom.ts new file mode 100644 index 0000000..878aac8 --- /dev/null +++ b/src/base/atom.ts @@ -0,0 +1,87 @@ +import { Atom, AtomStore, Listener, Read } from "./types.ts"; + +export const getValueFromCreator = (creator: T | Read) => { + const getState = (atom: Atom): T => atom.get(); + const syncCreator = creator as (get: (atom: Atom) => T) => T; + + return typeof creator === "function" ? syncCreator(getState) : creator; +}; + +export const createAtom = (atom: T): AtomStore => + ({ + value: atom, + subscribers: new Set>(), + subscribe(callback: Listener) { + this.subscribers.add(callback); + return () => this.subscribers.delete(callback); + }, + getState() { + return this.value; + }, + getInitState() { + return atom; + }, + setState(next: T) { + this.value = next; + }, + }) as AtomStore; + +export const createAtomApi = () => createAtom; + +const createBaseAtom = ( + creator: T | Read, + comparator?: ({ next, prev }: { next: T; prev: T }) => boolean, +) => { + const createNextAtom = createAtomApi(); + const atomStore = createNextAtom({} as any); + + const getState = (atom: Atom): T => atom.get(); + + const syncCreator = creator as (get: (atom: Atom) => T) => T; + + const init = typeof creator === "function" ? syncCreator(getState) : creator; + atomStore.setState(init); + + const atom = { + store: atomStore, + get: () => atomStore.getState(), + set: (next: T | Read) => { + const value = getValueFromCreator(next); + const shouldUpdate = + !comparator || + comparator({ + next: value, + prev: atomStore.getState(), + }); + + if (shouldUpdate) { + atomStore.setState(value); + atomStore.subscribers.forEach((callback) => { + callback(value); + }); + } + }, + subscribe: ( + callback: (next: T) => void, + callbackComparator?: ({ next, prev }: { next: T; prev: T }) => boolean, + ) => { + if (callbackComparator) { + const currentState = atomStore.getState(); + + atomStore.subscribers.add( + (state) => + callbackComparator({ + next: state as T, + prev: currentState as any, + }) && callback(state as T), + ); + } else { + atomStore.subscribers.add(callback as (state: any) => void); + } + }, + } as Atom; + + return atom; +}; + +export const atom = createBaseAtom; diff --git a/src/base/index.ts b/src/base/index.ts new file mode 100644 index 0000000..d88d07b --- /dev/null +++ b/src/base/index.ts @@ -0,0 +1,2 @@ +export { atom } from "./atom"; +export { create } from "./store"; diff --git a/src/base/store.ts b/src/base/store.ts new file mode 100644 index 0000000..1c34485 --- /dev/null +++ b/src/base/store.ts @@ -0,0 +1,178 @@ +import { Listener, Store } from "./types.ts"; + +export const createStoreApi = () => { + const implementStore = (state: T) => ({ + state, + subscribers: new Set>(), + subscribe(callback: Listener) { + this.subscribers.add(callback); + return () => this.subscribers.delete(callback); + }, + getState() { + return this.state; + }, + getInitState() { + return state; + }, + setState({ + next, + replace = false, + }: { + next: Partial; + replace?: boolean; + }) { + const nextState = + typeof next === "function" + ? (next as (next: T) => T)(next) + : (next as T); + + if (!Object.is(nextState, this.state)) { + this.state = + replace ?? (typeof nextState !== "object" || nextState === null) + ? nextState + : Object.assign({}, this.state, nextState); + } + }, + delete(callback: Listener) { + this.subscribers.delete(callback); + }, + }); + + return implementStore; +}; + +const useBaseExternalStoreWithSelector = ( + store: Store, + selector: (snapshot: Snapshot) => Selection, + comparator?: (a: Selection, b: Selection) => boolean, +) => { + const callback = (next: Snapshot) => { + const requestedUpdate = { + ...store.getState(), + ...(next ? next : {}), + }; + const currentState = selector(store.getState()); + const nextState = selector(requestedUpdate); + const shouldUpdate = comparator + ? comparator(currentState, nextState) + : true; + + shouldUpdate && + store.setState({ + next: requestedUpdate, + }); + }; + + store.subscribe(callback as any); + return selector(store.getState()); +}; + +export const createInternalBaseReference = (store: Store) => { + const useCreatedStore = ( + selector: (state: T) => U, + comparator?: ({ next, prev }: { next: U; prev: U }) => boolean, + ) => { + const selection = useBaseExternalStoreWithSelector( + store, + selector, + comparator + ? (a: U, b: U) => + comparator({ + next: a, + prev: b, + }) + : undefined, + ); + + const callback = (next: Partial) => { + const requestedUpdate = { + ...store.getState(), + ...(next ? next : {}), + }; + const currentState = selector(store.getState()); + const nextState = selector(requestedUpdate); + const shouldUpdate = comparator + ? comparator({ + next: currentState, + prev: nextState, + }) + : true; + + shouldUpdate && + store.setState({ + next: requestedUpdate, + }); + }; + + const callbackWithComparator = ( + callbackComparator: ({ next, prev }: { next: U; prev: U }) => boolean, + subscriptionCallback: (next: U) => void, + ) => { + const currentState = store.getState(); + + store.subscribers.add( + (state) => + callbackComparator({ + next: state as U, + prev: currentState as any, + }) && subscriptionCallback(state as U), + ); + }; + + return { + ...selection, + get() { + store.subscribe(callback); + return selector(store.getState()); + }, + set(state: Partial) { + store.subscribers.forEach((callback: Listener) => { + callback({ + ...store.getState(), + ...state, + }); + }); + }, + subscribe( + callback: (next: U) => void, + callbackComparator?: ({ next, prev }: { next: U; prev: U }) => boolean, + ) { + callbackComparator + ? callbackWithComparator(callbackComparator, callback) + : store.subscribers.add(callback as (state: any) => void); + }, + }; + }; + + Object.assign(useCreatedStore, store); + + return useCreatedStore; +}; + +const createBaseStoreFromState = () => { + const useCreatedStore = ( + creator: (set: (next: Partial) => void, get: () => U) => U, + ) => { + const createNextStore = createStoreApi(); + const store = createNextStore({} as any); + + const setState = (next: Partial): void => { + store.subscribers.forEach((callback) => { + callback(next); + }); + }; + + const getState = (): U => store.getState(); + + const init = creator(setState, getState); + store.setState({ + next: init, + }); + + return createInternalBaseReference(store); + }; + + return useCreatedStore; +}; + +export const create = createBaseStoreFromState(); diff --git a/src/types.ts b/src/base/types.ts similarity index 61% rename from src/types.ts rename to src/base/types.ts index 00ab441..7b304bd 100644 --- a/src/types.ts +++ b/src/base/types.ts @@ -1,6 +1,16 @@ export type Listener = (next: Partial) => void; -export type Atom = { +export type Store = { + state: S; + subscribers: Set>; + subscribe: (callback: Listener) => () => boolean; + getState: () => S; + getInitState: () => S; + setState: ({ next, replace }: { next: S; replace?: boolean }) => void; + delete: (callback: Listener) => void; +}; + +export type AtomStore = { value: T; update: (next: T) => void; subscribers: Set>; @@ -11,23 +21,20 @@ export type Atom = { delete: (callback: Listener) => void; }; -export type Store = { - state: S; - subscribers: Set>; - subscribe: (callback: Listener) => () => boolean; - getState: () => S; - getInitState: () => S; - setState: ({ next, replace }: { next: S; replace?: boolean }) => void; - delete: (callback: Listener) => void; +export type Atom = { + store: AtomStore; + get: (transform?: Read) => T; + set: (next: T | Read) => void; + subscribe: ( + callback: Listener, + comparator?: ({ next, prev }: { next: T; prev: T }) => boolean, + ) => void; }; -export type AtomHook = ( - selector: (value: T) => T, - comparator?: (({ next, prev }: { next: T; prev: T }) => boolean) | undefined, -) => [T, (next: T) => void]; - -export type DerivedAtom = ( - atom: T, - update: (set: (next: T) => T) => (next: T) => T | Promise, +export type Atomic = ( + creator: T | Read, link?: ((source: T, local: T) => T) | undefined, -) => [T, (next: T) => T | Promise]; +) => Atom; + +export type Getter = (atom: Atom) => Value; +export type Read = (get: Getter) => Value; diff --git a/src/index.ts b/src/index.ts index 9af6404..b1b5a08 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,3 @@ -export { atom, useAtom, atomAsync, atomBase } from "./atom.ts"; -export { create, createAsync, createBase } from "./store.ts"; -export { AtomHook, DerivedAtom } from "./types.ts"; +export { atom } from "./base"; +export { useAtom, create } from "./react"; +export { type Atomic } from "./base/types"; diff --git a/src/react/atom.ts b/src/react/atom.ts new file mode 100644 index 0000000..d137362 --- /dev/null +++ b/src/react/atom.ts @@ -0,0 +1,69 @@ +import { useMemo, useRef, useSyncExternalStore } from "react"; +import { createAtomApi, getValueFromCreator } from "~/base/atom.ts"; +import { Atom, Listener, Read } from "~/base/types.ts"; + +const createAtom = (creator: T | Read) => { + const createNextAtom = createAtomApi(); + const atomStore = createNextAtom({} as any); + + const init = getValueFromCreator(creator); + atomStore.setState(init); + + return { + store: atomStore, + get: (transform?: Read) => + transform ? getValueFromCreator(transform) : atomStore.getState(), + set: (next: T | Read) => { + const value = getValueFromCreator(next); + atomStore.setState(value); + atomStore.subscribers.forEach((callback: Listener) => { + callback(value); + }); + }, + subscribe: ( + callback: (next: T) => void, + callbackComparator?: ({ next, prev }: { next: T; prev: T }) => boolean, + ) => { + if (callbackComparator) { + const currentState = atomStore.getState(); + + atomStore.subscribers.add( + (state) => + callbackComparator({ + next: state as T, + prev: currentState as any, + }) && callback(state as T), + ); + } else { + atomStore.subscribers.add(callback as (state: any) => void); + } + }, + } as Atom; +}; + +export const useAtom = ( + creator: T | Read, + link?: (source: T, local: T) => T, +) => { + const atomRef = useRef(createAtom(creator)).current; + const lastLinkedState = useRef(getValueFromCreator(creator)); + + const creatorValue = getValueFromCreator(creator); + + useMemo(() => { + if (lastLinkedState.current !== creatorValue && link) { + atomRef.store.value = link(creatorValue, atomRef.store.value); + + lastLinkedState.current = creatorValue; + } + }, [creatorValue, lastLinkedState, atomRef, link]); + + const value = useSyncExternalStore( + (callback: Listener) => atomRef.store.subscribe(callback), + () => atomRef.store.value, + () => atomRef.store.value, + ); + atomRef.store.value = value; + + return atomRef; +}; diff --git a/src/react/index.ts b/src/react/index.ts new file mode 100644 index 0000000..b8d81bc --- /dev/null +++ b/src/react/index.ts @@ -0,0 +1,2 @@ +export { useAtom } from "./atom"; +export { create } from "./store"; diff --git a/src/react/store.ts b/src/react/store.ts new file mode 100644 index 0000000..e61c031 --- /dev/null +++ b/src/react/store.ts @@ -0,0 +1,99 @@ +import useSyncExports from "use-sync-external-store/shim/with-selector.js"; +import { Listener, Store } from "~/base/types.ts"; + +const { useSyncExternalStoreWithSelector } = useSyncExports; + +export const createStoreApi = () => { + const implementStore = (state: T) => ({ + state, + subscribers: new Set>(), + subscribe(callback: Listener) { + this.subscribers.add(callback); + return () => this.subscribers.delete(callback); + }, + getState() { + return this.state; + }, + getInitState() { + return state; + }, + setState({ + next, + replace = false, + }: { + next: Partial; + replace?: boolean; + }) { + const nextState = + typeof next === "function" + ? (next as (next: T) => T)(next) + : (next as T); + + if (!Object.is(nextState, this.state)) { + this.state = + replace ?? (typeof nextState !== "object" || nextState === null) + ? nextState + : Object.assign({}, this.state, nextState); + } + }, + delete(callback: Listener) { + this.subscribers.delete(callback); + }, + }); + + return implementStore; +}; + +const createInternalReference = (store: Store, init: T) => { + const useCreatedStore = ( + selector: (state: T) => U, + comparator?: ({ next, prev }: { next: U; prev: U }) => boolean, + ) => { + return useSyncExternalStoreWithSelector( + (callback: Listener) => store.subscribe(callback), + () => store.getState(), + () => init, + selector, + comparator + ? (a: U, b: U) => + comparator({ + next: a, + prev: b, + }) + : undefined, + ); + }; + + Object.assign(useCreatedStore, store); + + return useCreatedStore; +}; + +const createStoreFromState = () => { + const useCreatedStore = ( + creator: (set: (next: Partial) => void, get: () => U) => U, + ) => { + const createNextStore = createStoreApi(); + const store = createNextStore({} as any); + + const setState = (next: Partial): void => { + store.setState({ + next, + }); + store.subscribers.forEach((callback) => callback({})); + }; + + const getState = (): U => store.getState(); + + const init = creator(setState, getState); + store.setState({ + next: init, + }); + + return createInternalReference(store, init); + }; + + return useCreatedStore; +}; + +export const create = createStoreFromState(); diff --git a/src/store.ts b/src/store.ts deleted file mode 100644 index 8c496ca..0000000 --- a/src/store.ts +++ /dev/null @@ -1,251 +0,0 @@ -import useSyncExports from "use-sync-external-store/shim/with-selector.js"; -import { Listener, Store } from "./types.ts"; -import { useSyncExternalStoreWithSelectorAsync } from "./vanilla.ts"; - -const { useSyncExternalStoreWithSelector } = useSyncExports; - -export const createStoreApi = () => { - const implementStore = (state: T) => ({ - state, - subscribers: new Set>(), - subscribe(callback: Listener) { - this.subscribers.add(callback); - return () => this.subscribers.delete(callback); - }, - getState() { - return this.state; - }, - getInitState() { - return state; - }, - setState({ - next, - replace = false, - }: { - next: Partial; - replace?: boolean; - }) { - const nextState = - typeof next === "function" - ? (next as (next: T) => T)(next) - : (next as T); - - if (!Object.is(nextState, this.state)) { - this.state = - replace ?? (typeof nextState !== "object" || nextState === null) - ? nextState - : Object.assign({}, this.state, nextState); - } - }, - delete(callback: Listener) { - this.subscribers.delete(callback); - }, - }); - - return implementStore; -}; - -const createInternalReference = (store: Store, init: T) => { - const useCreatedStore = ( - selector: (state: T) => U, - comparator?: ({ next, prev }: { next: U; prev: U }) => boolean, - ) => { - return useSyncExternalStoreWithSelector( - (callback: Listener) => store.subscribe(callback), - () => store.getState(), - () => init, - selector, - comparator - ? (a: U, b: U) => - comparator({ - next: a, - prev: b, - }) - : undefined, - ); - }; - - Object.assign(useCreatedStore, store) - - return useCreatedStore; -}; - -const createStoreFromState = () => { - const useCreatedStore = ( - creator: (set: (next: Partial) => void, get: () => U) => U, - ) => { - const createNextStore = createStoreApi(); - const store = createNextStore({} as any); - - const setState = (next: Partial): void => { - store.setState({ - next, - }); - store.subscribers.forEach((callback) => callback({})); - }; - - const getState = (): U => store.getState(); - - const init = creator(setState, getState); - store.setState({ - next: init, - }); - - return createInternalReference(store, init); - }; - - return useCreatedStore; -}; - -const createInternalBaseReference = (store: Store) => { - const useCreatedStore = ( - selector: (state: T) => U, - comparator?: ({ next, prev }: { next: U; prev: U }) => boolean, - ) => { - const selection = useSyncExternalStoreWithSelectorAsync( - store, - selector, - comparator - ? (a: U, b: U) => - comparator({ - next: a, - prev: b, - }) - : undefined, - ); - - const callback = (next: Partial) => { - const requestedUpdate = { - ...store.getState(), - ...(next ? next : {}), - }; - const currentState = selector(store.getState()); - const nextState = selector(requestedUpdate); - const shouldUpdate = comparator - ? comparator({ - next: currentState, - prev: nextState, - }) - : true; - - shouldUpdate && - store.setState({ - next: requestedUpdate, - }); - }; - - const callbackWithComparator = ( - callbackComparator: ({ - next, - prev - }:{ - next: U, - prev: U - }) => boolean, - subscriptionCallback: (next: U) => void - ) => { - const currentState = store.getState() - - store.subscribers.add((state) => callbackComparator({ - next: state as U, - prev: currentState as any - }) && subscriptionCallback(state as U)); - } - - return { - ...selection, - get() { - store.subscribe(callback); - return selector(store.getState()); - }, - set(state: Partial) { - store.subscribers.forEach((callback) => { - callback({ - ...store.getState(), - ...state, - }); - }); - }, - subscribe( - callback: (next: U) => void, - callbackComparator?: ({ - next, - prev - }:{ - next: U, - prev: U - }) => boolean - ) { - callbackComparator ? callbackWithComparator( - callbackComparator, - callback - ) : store.subscribers.add( - callback as (state: any) => void - ); - }, - }; - }; - - Object.assign(useCreatedStore, store) - - return useCreatedStore; -}; - -const createAsyncStoreFromState = () => { - const useCreatedStore = async ( - creator: (set: (next: Partial) => void, get: () => U) => Promise, - ) => { - const createNextStore = createStoreApi(); - const store = createNextStore({} as any); - - const setState = (next: Partial): void => { - store.subscribers.forEach((callback) => { - callback(next); - }); - }; - - const getState = (): U => store.getState(); - - const init = await creator(setState, getState); - store.setState({ - next: init, - }); - - return createInternalBaseReference(store); - }; - - return useCreatedStore; -}; - - - -const createBaseStoreFromState = () => { - const useCreatedStore = ( - creator: (set: (next: Partial) => void, get: () => U) => U, - ) => { - const createNextStore = createStoreApi(); - const store = createNextStore({} as any); - - const setState = (next: Partial): void => { - store.subscribers.forEach((callback) => { - callback(next); - }); - }; - - const getState = (): U => store.getState(); - - const init = creator(setState, getState); - store.setState({ - next: init, - }); - - return createInternalBaseReference(store); - }; - - return useCreatedStore; -}; - - -export const create = createStoreFromState(); -export const createAsync = createAsyncStoreFromState(); -export const createBase = createBaseStoreFromState() \ No newline at end of file diff --git a/src/vanilla.ts b/src/vanilla.ts deleted file mode 100644 index 42ba872..0000000 --- a/src/vanilla.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Atom, Store } from "./types.ts"; - -export const useSyncExternalStoreWithSelectorAsync = ( - store: Store, - selector: (snapshot: Snapshot) => Selection, - comparator?: (a: Selection, b: Selection) => boolean, -) => { - const callback = (next: Snapshot) => { - const requestedUpdate = { - ...store.getState(), - ...(next ? next : {}), - }; - const currentState = selector(store.getState()); - const nextState = selector(requestedUpdate); - const shouldUpdate = comparator - ? comparator(currentState, nextState) - : true; - - shouldUpdate && - store.setState({ - next: requestedUpdate, - }); - }; - - store.subscribe(callback as any); - return selector(store.getState()); -}; - -export const useSyncExternalAtomWithSelectorAsync = ( - store: Atom, - selector: (snapshot: Snapshot) => Selection, - comparator?: (a: Selection, b: Selection) => boolean, -) => { - const callback = (next: Snapshot) => { - const currentState = selector(store.getState()); - const nextState = selector(next); - const shouldUpdate = comparator - ? comparator(currentState, nextState) - : true; - - shouldUpdate && store.setState(next); - }; - - store.subscribe(callback as any); - return selector(store.getState()); -}; diff --git a/tsconfig.json b/tsconfig.json index a606669..923b41f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,7 +27,10 @@ "allowImportingTsExtensions": true, "moduleResolution": "bundler", "noUncheckedIndexedAccess": true, - "baseUrl": "." + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + } }, "include": ["src"], "exclude": ["node_modules", "dist"]