diff --git a/.changeset/fluffy-llamas-dance.md b/.changeset/fluffy-llamas-dance.md new file mode 100644 index 0000000..13b6b45 --- /dev/null +++ b/.changeset/fluffy-llamas-dance.md @@ -0,0 +1,13 @@ +--- +'jotai-x': minor +--- + +- Atoms other than `atom` can now be passed in the `initialState` argument to `createAtomStore`. Primitive values use `atom` by default +- Added an `extend` option to `createAtomStore` that lets you add derived atoms to the store +- New accessors on `UseStoreApi` + - `useMyStore().store()` returns the `JotaiStore` for the current context, or undefined if no store exists + - `useMyStore().{get,set,use}.atom(someAtom)` accesses `someAtom` through the store +- Types: remove exports for some internal types + - `GetRecord` + - `SetRecord` + - `UseRecord` diff --git a/README.md b/README.md index 6079108..5f0a691 100644 --- a/README.md +++ b/README.md @@ -54,26 +54,25 @@ createAtomStore(initialState: T, options?: CreateAtomStoreOpti The **`options`** object can include several properties to customize the behavior of your store: - **`name`**: A string representing the name of the store, which can be helpful for debugging or when working with multiple stores. -- **`store`**: Allows specifying a [Jotai store](https://jotai.org/docs/core/store) if you want to use a custom one. Optional. - **`delay`**: If you need to introduce a delay in state updates, you can specify it here. Optional. - **`effect`**: A React component that can be used to run effects inside the provider. Optional. +- **`extend`**: Extend the store with derived atoms based on the store state. Optional. #### Return Value The **`createAtomStore`** function returns an object (**`AtomStoreApi`**) containing the following properties and methods for interacting with the store: - **`useStore`**: - - A function that returns the following objects: **`get`**, **`set`**, and **`use`**, where values are hooks for each state defined in the store. + - A function that returns the following objects: **`get`**, **`set`**, **`use`** and **`store`**, where values are hooks for each state defined in the store. - **`get`**: Hooks for accessing a state within a component, ensuring re-rendering when the state changes. See [useAtomValue](https://jotai.org/docs/core/use-atom#useatomvalue). - **`set`**: Hooks for setting a state within a component. See [useSetAtom](https://jotai.org/docs/core/use-atom#usesetatom). - **`use`**: Hooks for accessing and setting a state within a component, ensuring re-rendering when the state changes. See [useAtom](https://jotai.org/docs/core/use-atom). + - **`store`**: A hook to access the [JotaiStore](https://jotai.org/docs/core/store) for the current context. - Example: `const [element, setElement] = useElementStore().use.element()` - **`Provider`**: - The API includes dynamically generated provider components for each defined store. This allows scoped state management within your application. More information in the next section. - **`Store`**: - - Advanced API you generally don't need. - - **`atom`**: A hook for accessing state within a component, ensuring re-rendering when the state changes. See [atom](https://jotai.org/docs/core/atom). - - **`extend`**: Extends the store with additional atoms. + - **`atom`**: Access the atoms used by the store, including derived atoms defined using `extend`. See [atom](https://jotai.org/docs/core/atom). ### **Provider-Based Store Hydration and Synchronization** @@ -86,6 +85,40 @@ The **`createAtomStore`** function returns an object (**`AtomStoreApi`**) contai JotaiX creates scoped providers, enabling more granular control over different segments of state within your application. `createAtomStore` sets up a context for each store, which can be scoped using the **`scope`** prop. This is particularly beneficial in complex applications where nested providers are needed. +### Derived Atoms + +There are two ways of creating derived atoms from your JotaiX store. + +#### Derived Atoms Using `extend` + +Atoms defined using the `extend` option are made available in the same places as other values in the store. + +```ts +const { useUserStore } = createAtomStore({ + username: 'Alice', +}, { + name: 'user', + extend: (atoms) => ({ + intro: atom((get) => `My name is ${get(atoms.username)}`), + }), +}); + +const intro = useAppStore().get.intro(); +``` + +#### Externally Defined Derived Atoms + +Derived atoms can also be defined externally by accessing the store's atoms through the `Store` API. Externally defined atoms can be accessed through the store using the special `useStore().{get,set,use}.atom` hooks. + +```ts +const { userStore, useUserStore } = createAtomStore({ + username: 'Alice', +}, { name: 'user' }); + +const introAtom = atom((get) => `My name is ${get(userStore.atom.username)}`); +const intro = useUserStore().get.atom(introAtom); +``` + ### Example Usage #### 1. Create a store @@ -195,11 +228,6 @@ const Component = () => { ## Contributing -### Roadmap - -- [ ] Support other atoms like `atomWithStorage` -- [ ] Improve `extend` API to be more modular. - ### Ideas and discussions [Discussions](https://github.com/udecode/jotai-x/discussions) is the best diff --git a/package.json b/package.json index 76d710a..5754635 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "typecheck": "yarn g:typecheck", "typedoc": "npx typedoc --options scripts/typedoc.json", "release": "yarn build && yarn changeset publish", + "release:next": "yarn build && yarn changeset publish --tag next", "g:brl": "turbo --filter \"./packages/**\" brl --no-daemon", "g:build": "turbo --filter \"./packages/**\" build --no-daemon", "g:build:watch": "yarn build:watch", @@ -25,7 +26,6 @@ "g:clean": "yarn clean:turbo && turbo --filter \"./packages/**\" clean --no-daemon", "g:lint": "turbo --filter \"./packages/**\" lint --no-daemon", "g:lint:fix": "turbo lint:fix --no-daemon", - "g:release:next": "yarn yarn build && yarn changeset publish --tag next", "g:test": "turbo --filter \"./packages/**\" test --no-daemon", "g:test:watch": "turbo --filter \"./packages/**\" test:watch --no-daemon", "g:test:cov": "yarn g:test --coverage", diff --git a/packages/jotai-x/README.md b/packages/jotai-x/README.md index 6079108..5f0a691 100644 --- a/packages/jotai-x/README.md +++ b/packages/jotai-x/README.md @@ -54,26 +54,25 @@ createAtomStore(initialState: T, options?: CreateAtomStoreOpti The **`options`** object can include several properties to customize the behavior of your store: - **`name`**: A string representing the name of the store, which can be helpful for debugging or when working with multiple stores. -- **`store`**: Allows specifying a [Jotai store](https://jotai.org/docs/core/store) if you want to use a custom one. Optional. - **`delay`**: If you need to introduce a delay in state updates, you can specify it here. Optional. - **`effect`**: A React component that can be used to run effects inside the provider. Optional. +- **`extend`**: Extend the store with derived atoms based on the store state. Optional. #### Return Value The **`createAtomStore`** function returns an object (**`AtomStoreApi`**) containing the following properties and methods for interacting with the store: - **`useStore`**: - - A function that returns the following objects: **`get`**, **`set`**, and **`use`**, where values are hooks for each state defined in the store. + - A function that returns the following objects: **`get`**, **`set`**, **`use`** and **`store`**, where values are hooks for each state defined in the store. - **`get`**: Hooks for accessing a state within a component, ensuring re-rendering when the state changes. See [useAtomValue](https://jotai.org/docs/core/use-atom#useatomvalue). - **`set`**: Hooks for setting a state within a component. See [useSetAtom](https://jotai.org/docs/core/use-atom#usesetatom). - **`use`**: Hooks for accessing and setting a state within a component, ensuring re-rendering when the state changes. See [useAtom](https://jotai.org/docs/core/use-atom). + - **`store`**: A hook to access the [JotaiStore](https://jotai.org/docs/core/store) for the current context. - Example: `const [element, setElement] = useElementStore().use.element()` - **`Provider`**: - The API includes dynamically generated provider components for each defined store. This allows scoped state management within your application. More information in the next section. - **`Store`**: - - Advanced API you generally don't need. - - **`atom`**: A hook for accessing state within a component, ensuring re-rendering when the state changes. See [atom](https://jotai.org/docs/core/atom). - - **`extend`**: Extends the store with additional atoms. + - **`atom`**: Access the atoms used by the store, including derived atoms defined using `extend`. See [atom](https://jotai.org/docs/core/atom). ### **Provider-Based Store Hydration and Synchronization** @@ -86,6 +85,40 @@ The **`createAtomStore`** function returns an object (**`AtomStoreApi`**) contai JotaiX creates scoped providers, enabling more granular control over different segments of state within your application. `createAtomStore` sets up a context for each store, which can be scoped using the **`scope`** prop. This is particularly beneficial in complex applications where nested providers are needed. +### Derived Atoms + +There are two ways of creating derived atoms from your JotaiX store. + +#### Derived Atoms Using `extend` + +Atoms defined using the `extend` option are made available in the same places as other values in the store. + +```ts +const { useUserStore } = createAtomStore({ + username: 'Alice', +}, { + name: 'user', + extend: (atoms) => ({ + intro: atom((get) => `My name is ${get(atoms.username)}`), + }), +}); + +const intro = useAppStore().get.intro(); +``` + +#### Externally Defined Derived Atoms + +Derived atoms can also be defined externally by accessing the store's atoms through the `Store` API. Externally defined atoms can be accessed through the store using the special `useStore().{get,set,use}.atom` hooks. + +```ts +const { userStore, useUserStore } = createAtomStore({ + username: 'Alice', +}, { name: 'user' }); + +const introAtom = atom((get) => `My name is ${get(userStore.atom.username)}`); +const intro = useUserStore().get.atom(introAtom); +``` + ### Example Usage #### 1. Create a store @@ -195,11 +228,6 @@ const Component = () => { ## Contributing -### Roadmap - -- [ ] Support other atoms like `atomWithStorage` -- [ ] Improve `extend` API to be more modular. - ### Ideas and discussions [Discussions](https://github.com/udecode/jotai-x/discussions) is the best diff --git a/packages/jotai-x/src/atomWithFn.ts b/packages/jotai-x/src/atomWithFn.ts new file mode 100644 index 0000000..5c759a1 --- /dev/null +++ b/packages/jotai-x/src/atomWithFn.ts @@ -0,0 +1,32 @@ +import { atom } from 'jotai'; + +import type { WritableAtom } from 'jotai/vanilla'; + +type WrapFn = T extends (...args: infer _A) => infer _R ? { __fn: T } : T; + +const wrapFn = (fnOrValue: T): WrapFn => + (typeof fnOrValue === 'function' ? { __fn: fnOrValue } : fnOrValue) as any; + +type UnwrapFn = T extends { __fn: infer U } ? U : T; + +const unwrapFn = (wrappedFnOrValue: T): UnwrapFn => + (wrappedFnOrValue && + typeof wrappedFnOrValue === 'object' && + '__fn' in wrappedFnOrValue + ? wrappedFnOrValue.__fn + : wrappedFnOrValue) as any; + +/** + * Jotai atoms don't allow functions as values by default. This function is a + * drop-in replacement for `atom` that wraps functions in an object while + * leaving non-functions unchanged. The wrapper object should be completely + * invisible to consumers of the atom. + */ +export const atomWithFn = (initialValue: T): WritableAtom => { + const baseAtom = atom(wrapFn(initialValue)); + + return atom( + (get) => unwrapFn(get(baseAtom)) as T, + (_get, set, value) => set(baseAtom, wrapFn(value)) + ); +}; diff --git a/packages/jotai-x/src/createAtomProvider.tsx b/packages/jotai-x/src/createAtomProvider.tsx index 5412023..efcc6d8 100644 --- a/packages/jotai-x/src/createAtomProvider.tsx +++ b/packages/jotai-x/src/createAtomProvider.tsx @@ -9,7 +9,7 @@ import React, { import { createStore } from 'jotai/vanilla'; import { AtomProvider, AtomProviderProps } from './atomProvider'; -import { AtomRecord, JotaiStore } from './createAtomStore'; +import { JotaiStore, SimpleWritableAtomRecord } from './createAtomStore'; import { useHydrateStore, useSyncStore } from './useHydrateStore'; const getFullyQualifiedScope = (storeName: string, scope: string) => { @@ -62,7 +62,7 @@ export const HydrateAtoms = ({ atoms, ...props }: Omit, 'scope'> & { - atoms: AtomRecord; + atoms: SimpleWritableAtomRecord; }) => { useHydrateStore(atoms, { ...initialValues, ...props } as any, { store, @@ -81,7 +81,7 @@ export const HydrateAtoms = ({ */ export const createAtomProvider = ( storeScope: N, - atoms: AtomRecord, + atoms: SimpleWritableAtomRecord, options: { effect?: FC } = {} ) => { const Effect = options.effect; diff --git a/packages/jotai-x/src/createAtomStore.spec.tsx b/packages/jotai-x/src/createAtomStore.spec.tsx index 9d911f2..b519f32 100644 --- a/packages/jotai-x/src/createAtomStore.spec.tsx +++ b/packages/jotai-x/src/createAtomStore.spec.tsx @@ -1,7 +1,9 @@ import '@testing-library/jest-dom'; import React, { ReactNode, useState } from 'react'; -import { act, render, renderHook } from '@testing-library/react'; +import { act, queryByText, render, renderHook } from '@testing-library/react'; +import { atom, PrimitiveAtom, useAtomValue } from 'jotai'; +import { splitAtom } from 'jotai/utils'; import { createAtomStore } from './createAtomStore'; @@ -10,6 +12,7 @@ describe('createAtomStore', () => { type MyTestStoreValue = { name: string; age: number; + becomeFriends: () => void; }; const INITIAL_NAME = 'John'; @@ -18,16 +21,15 @@ describe('createAtomStore', () => { const initialTestStoreValue: MyTestStoreValue = { name: INITIAL_NAME, age: INITIAL_AGE, + becomeFriends: () => {}, }; - const { useMyTestStoreStore, MyTestStoreProvider } = createAtomStore( - initialTestStoreValue, - { name: 'myTestStore' as const } - ); + const { myTestStoreStore, useMyTestStoreStore, MyTestStoreProvider } = + createAtomStore(initialTestStoreValue, { name: 'myTestStore' as const }); const ReadOnlyConsumer = () => { - const [name] = useMyTestStoreStore().use.name(); - const [age] = useMyTestStoreStore().use.age(); + const name = useMyTestStoreStore().get.name(); + const age = useMyTestStoreStore().get.age(); return (
@@ -69,6 +71,76 @@ describe('createAtomStore', () => { ); }; + const BecomeFriendsProvider = ({ children }: { children: ReactNode }) => { + const [becameFriends, setBecameFriends] = useState(false); + + return ( + <> + setBecameFriends(true)}> + {children} + + +
becameFriends: {becameFriends.toString()}
+ + ); + }; + + const BecomeFriendsGetter = () => { + // Make sure both of these are actual functions, not wrapped functions + const becomeFriends1 = useMyTestStoreStore().get.becomeFriends(); + const becomeFriends2 = useMyTestStoreStore().get.atom( + myTestStoreStore.atom.becomeFriends + ); + + return ( + + ); + }; + + const BecomeFriendsSetter = () => { + const setBecomeFriends = useMyTestStoreStore().set.becomeFriends(); + const [becameFriends, setBecameFriends] = useState(false); + + return ( + <> + + +
setterBecameFriends: {becameFriends.toString()}
+ + ); + }; + + const BecomeFriendsUser = () => { + const [, setBecomeFriends] = useMyTestStoreStore().use.becomeFriends(); + const [becameFriends, setBecameFriends] = useState(false); + + return ( + <> + + +
userBecameFriends: {becameFriends.toString()}
+ + ); + }; + beforeEach(() => { renderHook(() => useMyTestStoreStore().set.name()(INITIAL_NAME)); renderHook(() => useMyTestStoreStore().set.age()(INITIAL_AGE)); @@ -155,6 +227,46 @@ describe('createAtomStore', () => { expect(getByText(INITIAL_NAME)).toBeInTheDocument(); expect(getByText(WRITE_ONLY_CONSUMER_AGE)).toBeInTheDocument(); }); + + it('provides and gets functions', () => { + const { getByText } = render( + + + + ); + + expect(getByText('becameFriends: false')).toBeInTheDocument(); + act(() => getByText('Become Friends').click()); + expect(getByText('becameFriends: true')).toBeInTheDocument(); + }); + + it('sets functions', () => { + const { getByText } = render( + + + + + ); + + act(() => getByText('Change Callback').click()); + expect(getByText('setterBecameFriends: false')).toBeInTheDocument(); + act(() => getByText('Become Friends').click()); + expect(getByText('setterBecameFriends: true')).toBeInTheDocument(); + }); + + it('uses functions', () => { + const { getByText } = render( + + + + + ); + + act(() => getByText('Change Callback').click()); + expect(getByText('userBecameFriends: false')).toBeInTheDocument(); + act(() => getByText('Become Friends').click()); + expect(getByText('userBecameFriends: true')).toBeInTheDocument(); + }); }); describe('scoped providers', () => { @@ -170,7 +282,7 @@ describe('createAtomStore', () => { }); const ReadOnlyConsumer = ({ scope }: { scope: string }) => { - const [age] = useMyScopedTestStoreStore().use.age({ scope }); + const age = useMyScopedTestStoreStore().get.age({ scope }); return (
@@ -184,7 +296,7 @@ describe('createAtomStore', () => { }: { scope: string; }) => { - const [age] = useMyScopedTestStoreStore(scope).use.age(); + const age = useMyScopedTestStoreStore(scope).get.age(); return (
@@ -269,7 +381,7 @@ describe('createAtomStore', () => { }); const FirstReadOnlyConsumer = () => { - const [name] = useMyFirstTestStoreStore().use.name(); + const name = useMyFirstTestStoreStore().get.name(); return (
@@ -279,7 +391,7 @@ describe('createAtomStore', () => { }; const SecondReadOnlyConsumer = () => { - const [age] = useMySecondTestStoreStore().use.age(); + const age = useMySecondTestStoreStore().get.age(); return (
@@ -302,4 +414,299 @@ describe('createAtomStore', () => { expect(getByText('98')).toBeInTheDocument(); }); }); + + describe('extended stores', () => { + type User = { + name: string; + age: number; + }; + + const initialUser: User = { + name: 'Jane', + age: 98, + }; + + const { userStore, useUserStore, UserProvider } = createAtomStore( + initialUser, + { + name: 'user' as const, + extend: ({ name, age }) => ({ + bio: atom((get) => `${get(name)} is ${get(age)} years old`), + }), + } + ); + + const ReadOnlyConsumer = () => { + const bio = useUserStore().get.bio(); + + return
{bio}
; + }; + + it('includes extended atom in store object', () => { + const { result } = renderHook(() => useAtomValue(userStore.atom.bio)); + expect(result.current).toBe('Jane is 98 years old'); + }); + + it('includes extended atom in get hooks', () => { + const { result } = renderHook(() => useUserStore().get.bio()); + expect(result.current).toBe('Jane is 98 years old'); + }); + + it('does not include read-only extended atom in set hooks', () => { + const { result } = renderHook(() => Object.keys(useUserStore().set)); + expect(result.current).not.toContain('bio'); + }); + + it('does not include read-only extended atom in use hooks', () => { + const { result } = renderHook(() => Object.keys(useUserStore().use)); + expect(result.current).not.toContain('bio'); + }); + + it('computes extended atom based on current state', () => { + const { getByText } = render( + + + + ); + + expect(getByText('John is 42 years old')).toBeInTheDocument(); + }); + }); + + describe('passing atoms as part of initial state', () => { + type CustomAtom = PrimitiveAtom & { + isCustomAtom: true; + }; + + const createCustomAtom = (value: T): CustomAtom => ({ + ...atom(value), + isCustomAtom: true, + }); + + const { customStore } = createAtomStore( + { + x: createCustomAtom(1), + }, + { + name: 'custom' as const, + } + ); + + it('uses passed atom', () => { + const myAtom = customStore.atom.x as CustomAtom; + expect(myAtom.isCustomAtom).toBe(true); + }); + }); + + describe('arbitrary atom accessors', () => { + type User = { + name: string; + }; + + const initialUser: User = { + name: 'Jane', + }; + + const { userStore, useUserStore, UserProvider } = createAtomStore( + initialUser, + { + name: 'user' as const, + } + ); + + const derivedAtom = atom((get) => `My name is ${get(userStore.atom.name)}`); + + const DerivedAtomConsumer = () => { + const message = useUserStore().get.atom(derivedAtom); + + return
{message}
; + }; + + it('accesses arbitrary atom within store', () => { + const { getByText } = render( + + + + ); + + expect(getByText('My name is John')).toBeInTheDocument(); + }); + }); + + describe('splitAtoms using todoStore.atom.items', () => { + const initialState = { + items: [] as { + task: string; + done: boolean; + }[], + }; + + const { todoStore, useTodoStore, TodoProvider } = createAtomStore( + initialState, + { + name: 'todo' as const, + } + ); + + const todoAtomsAtom = splitAtom(todoStore.atom.items); + + type TodoType = (typeof initialState)['items'][number]; + + const TodoItem = ({ + todoAtom, + remove, + }: { + todoAtom: PrimitiveAtom; + remove: () => void; + }) => { + const [todo, setTodo] = useTodoStore().use.atom(todoAtom); + + return ( +
+ + { + setTodo((oldValue) => ({ ...oldValue, done: !oldValue.done })); + }} + /> + {/* eslint-disable-next-line react/button-has-type */} + +
+ ); + }; + + const TodoList = () => { + const [todoAtoms, dispatch] = useTodoStore().use.atom(todoAtomsAtom); + return ( +
    + {todoAtoms.map((todoAtom) => ( + dispatch({ type: 'remove', atom: todoAtom })} + /> + ))} +
+ ); + }; + + it('should work', () => { + const { getByText, container } = render( + + + + ); + + expect(getByText('help the town')).toBeInTheDocument(); + expect(getByText('feed the dragon')).toBeInTheDocument(); + + act(() => getByText('remove help the town').click()); + + expect(queryByText(container, 'help the town')).not.toBeInTheDocument(); + expect(getByText('feed the dragon')).toBeInTheDocument(); + }); + }); + + describe('splitAtoms using extend', () => { + const initialState = { + items: [] as { + task: string; + done: boolean; + }[], + }; + + const { useTodoStore, TodoProvider } = createAtomStore(initialState, { + name: 'todo' as const, + extend: ({ items }) => ({ + itemAtoms: splitAtom(items), + }), + }); + + type TodoType = (typeof initialState)['items'][number]; + + const TodoItem = ({ + todoAtom, + remove, + }: { + todoAtom: PrimitiveAtom; + remove: () => void; + }) => { + const [todo, setTodo] = useTodoStore().use.atom(todoAtom); + + return ( +
+ + { + setTodo((oldValue) => ({ ...oldValue, done: !oldValue.done })); + }} + /> + {/* eslint-disable-next-line react/button-has-type */} + +
+ ); + }; + + const TodoList = () => { + const [todoAtoms, dispatch] = useTodoStore().use.itemAtoms(); + + return ( +
    + {todoAtoms.map((todoAtom) => ( + dispatch({ type: 'remove', atom: todoAtom })} + /> + ))} +
+ ); + }; + + it('should work', () => { + const { getByText, container } = render( + + + + ); + + expect(getByText('help the town')).toBeInTheDocument(); + expect(getByText('feed the dragon')).toBeInTheDocument(); + + act(() => getByText('remove help the town').click()); + + expect(queryByText(container, 'help the town')).not.toBeInTheDocument(); + expect(getByText('feed the dragon')).toBeInTheDocument(); + }); + }); }); diff --git a/packages/jotai-x/src/createAtomStore.ts b/packages/jotai-x/src/createAtomStore.ts index fa675c8..8f5f769 100644 --- a/packages/jotai-x/src/createAtomStore.ts +++ b/packages/jotai-x/src/createAtomStore.ts @@ -1,17 +1,13 @@ -import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { useHydrateAtoms } from 'jotai/utils'; +import { atomWithFn } from './atomWithFn'; import { createAtomProvider, useAtomStore } from './createAtomProvider'; import type { ProviderProps } from './createAtomProvider'; import type { FC } from 'react'; -import type { PrimitiveAtom } from 'jotai'; -import type { useHydrateAtoms } from 'jotai/utils'; -import type { createStore } from 'jotai/vanilla'; +import type { Atom, createStore, WritableAtom } from 'jotai/vanilla'; -type WithInitialValue = { - init: Value; -}; -type Atom = PrimitiveAtom & WithInitialValue; export type JotaiStore = ReturnType; export type UseAtomOptions = { @@ -22,17 +18,42 @@ export type UseAtomOptions = { type UseAtomOptionsOrScope = UseAtomOptions | string; -export type GetRecord = { - [K in keyof O]: (options?: UseAtomOptionsOrScope) => O[K]; +type GetRecord = { + [K in keyof O]: O[K] extends Atom + ? (options?: UseAtomOptionsOrScope) => V + : never; +}; + +type SetRecord = { + [K in keyof O]: O[K] extends WritableAtom + ? (options?: UseAtomOptionsOrScope) => (...args: A) => R + : never; +}; + +type UseRecord = { + [K in keyof O]: O[K] extends WritableAtom + ? (options?: UseAtomOptionsOrScope) => [V, (...args: A) => R] + : never; }; -export type SetRecord = { - [K in keyof O]: (options?: UseAtomOptionsOrScope) => (value: O[K]) => void; + +type StoreAtomsWithoutExtend = { + [K in keyof T]: T[K] extends Atom ? T[K] : SimpleWritableAtom; }; -export type UseRecord = { - [K in keyof O]: ( - options?: UseAtomOptionsOrScope - ) => [O[K], (value: O[K]) => void]; + +type StoreAtoms = StoreAtomsWithoutExtend & E; + +type FilterWritableAtoms = { + [K in keyof T]-?: T[K] extends WritableAtom ? T[K] : never; }; + +type WritableStoreAtoms = FilterWritableAtoms>; + +export type SimpleWritableAtom = WritableAtom; + +export type SimpleWritableAtomRecord = { + [K in keyof T]: SimpleWritableAtom; +}; + export type AtomRecord = { [K in keyof O]: Atom; }; @@ -51,32 +72,46 @@ export type UseSyncAtoms = ( } ) => void; -export type StoreApi = { - atom: AtomRecord; +export type StoreApi< + T extends object, + E extends AtomRecord, + N extends string = '', +> = { + atom: StoreAtoms; name: N; - extend: ( - extendedState: ET, - options?: Omit< - CreateAtomStoreOptions, - 'initialStore' - > - ) => AtomStoreApi; }; -export type UseStoreApi = (options?: UseAtomOptionsOrScope) => { - get: GetRecord; - set: SetRecord; - use: UseRecord; +type GetAtomFn = (atom: Atom, options?: UseAtomOptionsOrScope) => V; + +type SetAtomFn = ( + atom: WritableAtom, + options?: UseAtomOptionsOrScope +) => (...args: A) => R; + +type UseAtomFn = ( + atom: WritableAtom, + options?: UseAtomOptionsOrScope +) => [V, (...args: A) => R]; + +export type UseStoreApi = (options?: UseAtomOptionsOrScope) => { + get: GetRecord> & { atom: GetAtomFn }; + set: SetRecord> & { atom: SetAtomFn }; + use: UseRecord> & { atom: UseAtomFn }; + store: (options?: UseAtomOptionsOrScope) => JotaiStore | undefined; }; -export type AtomStoreApi = { +export type AtomStoreApi< + T extends object, + E extends AtomRecord, + N extends string = '', +> = { name: N; } & { [key in keyof Record, object>]: FC>; } & { - [key in keyof Record, object>]: StoreApi; + [key in keyof Record, object>]: StoreApi; } & { - [key in keyof Record, object>]: UseStoreApi; + [key in keyof Record, object>]: UseStoreApi; }; const capitalizeFirstLetter = (str = '') => @@ -88,12 +123,18 @@ const getStoreIndex = (name = '') => const getUseStoreIndex = (name = '') => `use${capitalizeFirstLetter(name)}Store`; -const withDefaultOptions = ( - atomRecord: AtomRecord, +const isAtom = (possibleAtom: unknown): boolean => + !!possibleAtom && + typeof possibleAtom === 'object' && + 'read' in possibleAtom && + typeof possibleAtom.read === 'function'; + +const withDefaultOptions = ( + fnRecord: T, defaultOptions: UseAtomOptions -): AtomRecord => +): T => Object.fromEntries( - Object.entries(atomRecord).map(([key, fn]) => [ + Object.entries(fnRecord).map(([key, fn]) => [ key, (options: UseAtomOptions = {}) => (fn as any)({ ...defaultOptions, ...options }), @@ -107,12 +148,15 @@ const convertScopeShorthand = ( ? { scope: optionsOrScope } : optionsOrScope; -export interface CreateAtomStoreOptions { - store?: UseAtomOptions['store']; +export interface CreateAtomStoreOptions< + T extends object, + E extends AtomRecord, + N extends string, +> { + name: N; delay?: UseAtomOptions['delay']; - initialStore?: AtomStoreApi; - name?: N; effect?: FC; + extend?: (atomsWithoutExtend: StoreAtomsWithoutExtend) => E; } /** @@ -129,94 +173,153 @@ export interface CreateAtomStoreOptions { */ export const createAtomStore = < T extends object, - IT extends object, + E extends AtomRecord, N extends string = '', >( initialState: T, - { - delay: delayRoot, - initialStore, - name = '' as any, - effect, - }: CreateAtomStoreOptions = {} -): AtomStoreApi => { - const useInitialStoreIndex = getUseStoreIndex( - initialStore?.name - ) as UseNameStore; - const initialStoreIndex = getStoreIndex(initialStore?.name) as NameStore; + { name, delay: delayRoot, effect, extend }: CreateAtomStoreOptions +): AtomStoreApi => { + type MyStoreAtoms = StoreAtoms; + type MyWritableStoreAtoms = WritableStoreAtoms; + type MyStoreAtomsWithoutExtend = StoreAtomsWithoutExtend; + type MyWritableStoreAtomsWithoutExtend = + FilterWritableAtoms; + const providerIndex = getProviderIndex(name) as NameProvider; const useStoreIndex = getUseStoreIndex(name) as UseNameStore; const storeIndex = getStoreIndex(name) as NameStore; - // FIXME: These constants have type any - const getAtoms = initialStore - ? (initialStore[useInitialStoreIndex] as any)().get - : ({} as GetRecord); - const setAtoms = initialStore - ? (initialStore[useInitialStoreIndex] as any)().set - : ({} as SetRecord); - const useAtoms = initialStore - ? (initialStore[useInitialStoreIndex] as any)().use - : ({} as UseRecord); - const atoms = initialStore - ? (initialStore[initialStoreIndex] as any).atom - : ({} as AtomRecord); - - for (const key of Object.keys(initialState)) { - const atomConfig = atom(initialState[key as keyof T]); - - atoms[key] = atomConfig; - getAtoms[key] = (optionsOrScope: UseAtomOptionsOrScope = {}) => { - const options = convertScopeShorthand(optionsOrScope); - const contextStore = useAtomStore(name, options.scope, false); - - return useAtomValue(atomConfig, { - store: options.store ?? contextStore, - delay: options.delay ?? delayRoot, - }); - }; - setAtoms[key] = (optionsOrScope: UseAtomOptionsOrScope = {}) => { - const options = convertScopeShorthand(optionsOrScope); - const contextStore = useAtomStore(name, options.scope); - - return useSetAtom(atomConfig as any, { - store: options.store ?? contextStore, - }); - }; - useAtoms[key] = (optionsOrScope: UseAtomOptionsOrScope = {}) => { - const options = convertScopeShorthand(optionsOrScope); - const contextStore = useAtomStore(name, options.scope); - - return useAtom(atomConfig, { - store: options.store ?? contextStore, - delay: options.delay ?? delayRoot, - }); - }; + const atomsWithoutExtend = {} as MyStoreAtomsWithoutExtend; + const writableAtomsWithoutExtend = {} as MyWritableStoreAtomsWithoutExtend; + const atomIsWritable = {} as Record; + + for (const [key, atomOrValue] of Object.entries(initialState)) { + const atomConfig: Atom = isAtom(atomOrValue) + ? atomOrValue + : atomWithFn(atomOrValue); + atomsWithoutExtend[key as keyof MyStoreAtomsWithoutExtend] = + atomConfig as any; + + const writable = 'write' in atomConfig; + atomIsWritable[key as keyof MyStoreAtoms] = writable; + + if (writable) { + writableAtomsWithoutExtend[ + key as keyof MyWritableStoreAtomsWithoutExtend + ] = atomConfig as any; + } } - const api: any = { - [providerIndex]: createAtomProvider(name, atoms, { effect }), - [useStoreIndex]: (options: UseAtomOptionsOrScope = {}) => ({ - get: withDefaultOptions(getAtoms, convertScopeShorthand(options)), - set: withDefaultOptions(setAtoms, convertScopeShorthand(options)), - use: withDefaultOptions(useAtoms, convertScopeShorthand(options)), - }), - [storeIndex]: { - atom: atoms, - name, - }, + const atoms = { ...atomsWithoutExtend } as MyStoreAtoms; + + if (extend) { + const extendedAtoms = extend(atomsWithoutExtend); + + for (const [key, atomConfig] of Object.entries(extendedAtoms)) { + atoms[key as keyof MyStoreAtoms] = atomConfig; + atomIsWritable[key as keyof MyStoreAtoms] = 'write' in atomConfig; + } + } + + const getAtoms = {} as GetRecord; + const setAtoms = {} as SetRecord; + const useAtoms = {} as UseRecord; + + const useStore = ( + optionsOrScope: UseAtomOptionsOrScope = {}, + warnIfUndefined = true + ) => { + const { scope, store } = convertScopeShorthand(optionsOrScope); + const contextStore = useAtomStore(name, scope, warnIfUndefined); + return store ?? contextStore; + }; + + const useAtomValueWithStore: GetAtomFn = (atomConfig, optionsOrScope) => { + const store = useStore(optionsOrScope, false); + const { delay = delayRoot } = convertScopeShorthand(optionsOrScope); + return useAtomValue(atomConfig, { store, delay }); + }; + + const useSetAtomWithStore: SetAtomFn = (atomConfig, optionsOrScope) => { + const store = useStore(optionsOrScope); + return useSetAtom(atomConfig, { store }); + }; + + const useAtomWithStore: UseAtomFn = (atomConfig, optionsOrScope) => { + const store = useStore(optionsOrScope); + const { delay = delayRoot } = convertScopeShorthand(optionsOrScope); + return useAtom(atomConfig, { store, delay }); + }; + + for (const key of Object.keys(atoms)) { + const atomConfig = atoms[key as keyof MyStoreAtoms]; + const isWritable: boolean = atomIsWritable[key as keyof MyStoreAtoms]; + + (getAtoms as any)[key] = (optionsOrScope: UseAtomOptionsOrScope = {}) => + useAtomValueWithStore(atomConfig, optionsOrScope); + + if (isWritable) { + (setAtoms as any)[key] = (optionsOrScope: UseAtomOptionsOrScope = {}) => + useSetAtomWithStore( + atomConfig as WritableAtom, + optionsOrScope + ); + + (useAtoms as any)[key] = (optionsOrScope: UseAtomOptionsOrScope = {}) => + useAtomWithStore( + atomConfig as WritableAtom, + optionsOrScope + ); + } + } + + const Provider: FC> = createAtomProvider( + name, + writableAtomsWithoutExtend, + { effect } + ); + + const storeApi: StoreApi = { + atom: atoms, name, }; - return { - ...api, - [storeIndex]: { - ...api[storeIndex], - extend: (extendedState: any, options: any) => - createAtomStore(extendedState, { - initialStore: api, - ...options, + const useStoreApi: UseStoreApi = (defaultOptions = {}) => ({ + get: { + ...withDefaultOptions(getAtoms, convertScopeShorthand(defaultOptions)), + atom: (atomConfig, options) => + useAtomValueWithStore(atomConfig, { + ...convertScopeShorthand(defaultOptions), + ...convertScopeShorthand(options), }), }, - }; + set: { + ...withDefaultOptions(setAtoms, convertScopeShorthand(defaultOptions)), + atom: (atomConfig, options) => + useSetAtomWithStore(atomConfig, { + ...convertScopeShorthand(defaultOptions), + ...convertScopeShorthand(options), + }), + }, + use: { + ...withDefaultOptions(useAtoms, convertScopeShorthand(defaultOptions)), + atom: (atomConfig, options) => + useAtomWithStore(atomConfig, { + ...convertScopeShorthand(defaultOptions), + ...convertScopeShorthand(options), + }), + }, + store: (options) => + useStore({ + ...convertScopeShorthand(defaultOptions), + ...convertScopeShorthand(options), + }), + }); + + return { + [providerIndex]: Provider, + [useStoreIndex]: useStoreApi, + [storeIndex]: storeApi, + name, + } as any; }; diff --git a/packages/jotai-x/src/index.ts b/packages/jotai-x/src/index.ts index f63fe17..b9d1112 100644 --- a/packages/jotai-x/src/index.ts +++ b/packages/jotai-x/src/index.ts @@ -3,6 +3,7 @@ */ export * from './atomProvider'; +export * from './atomWithFn'; export * from './createAtomProvider'; export * from './createAtomStore'; export * from './useHydrateStore'; diff --git a/packages/jotai-x/src/useHydrateStore.ts b/packages/jotai-x/src/useHydrateStore.ts index 9dbc595..8af7ffe 100644 --- a/packages/jotai-x/src/useHydrateStore.ts +++ b/packages/jotai-x/src/useHydrateStore.ts @@ -2,8 +2,8 @@ import { useEffect } from 'react'; import { useSetAtom } from 'jotai'; import { useHydrateAtoms } from 'jotai/utils'; -import type { - AtomRecord, +import { + SimpleWritableAtomRecord, UseHydrateAtoms, UseSyncAtoms, } from './createAtomStore'; @@ -12,7 +12,7 @@ import type { * Hydrate atoms with initial values for SSR. */ export const useHydrateStore = ( - atoms: AtomRecord, + atoms: SimpleWritableAtomRecord, initialValues: Parameters>[0], options: Parameters>[1] = {} ) => { @@ -22,12 +22,7 @@ export const useHydrateStore = ( const initialValue = initialValues[key]; if (initialValue !== undefined) { - values.push([ - atoms[key], - typeof initialValue === 'function' - ? { fn: initialValue } - : initialValue, - ]); + values.push([atoms[key], initialValue]); } } @@ -38,7 +33,7 @@ export const useHydrateStore = ( * Update atoms with new values on changes. */ export const useSyncStore = ( - atoms: AtomRecord, + atoms: SimpleWritableAtomRecord, values: any, { store }: Parameters>[1] = {} ) => { @@ -50,7 +45,7 @@ export const useSyncStore = ( useEffect(() => { if (value !== undefined && value !== null) { - set(typeof value === 'function' ? { fn: value } : value); + set(value); } }, [set, value]); }