diff --git a/src/utils.ts b/src/utils.ts index c0488144d7..889ecdad84 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -19,3 +19,4 @@ export { } from './utils/atomWithStorage' export { atomWithObservable } from './utils/atomWithObservable' export { useHydrateAtoms } from './utils/useHydrateAtoms' +export { loadable } from './utils/loadable' diff --git a/src/utils/loadable.ts b/src/utils/loadable.ts new file mode 100644 index 0000000000..a91771f66f --- /dev/null +++ b/src/utils/loadable.ts @@ -0,0 +1,52 @@ +import { atom } from 'jotai' +import type { Atom } from 'jotai' + +const loadableAtomCache = new WeakMap, Atom>>() +const errorLoadableCache = new WeakMap>() + +type Loadable = + | { state: 'loading' } + | { state: 'hasError'; error: unknown } + | { state: 'hasData'; data: Value } + +const LOADING_LOADABLE: Loadable = { state: 'loading' } + +export function loadable(anAtom: Atom): Atom> { + const cachedAtom = loadableAtomCache.get(anAtom) + if (cachedAtom) { + return cachedAtom as Atom> + } + + const derivedAtom = atom((get): Loadable => { + try { + const value = get(anAtom) + + return { + state: 'hasData', + data: value, + } + } catch (error) { + if (error instanceof Promise) { + return LOADING_LOADABLE + } + + const cachedErrorLoadable = errorLoadableCache.get(error as Error) + + if (cachedErrorLoadable) { + return cachedErrorLoadable + } + + const errorLoadable: Loadable = { + state: 'hasError', + error, + } + + errorLoadableCache.set(error as Error, errorLoadable) + return errorLoadable + } + }) + + loadableAtomCache.set(anAtom, derivedAtom) + + return derivedAtom +} diff --git a/tests/utils/loadable.test.tsx b/tests/utils/loadable.test.tsx new file mode 100644 index 0000000000..ecc8e43ffd --- /dev/null +++ b/tests/utils/loadable.test.tsx @@ -0,0 +1,149 @@ +import { fireEvent, render } from '@testing-library/react' +import { Atom, atom } from '../../src/index' +import { loadable, useAtomValue, useUpdateAtom } from '../../src/utils' +import { getTestProvider } from '../testUtils' + +const Provider = getTestProvider() + +it('loadable turns suspense into values', async () => { + let resolveAsync!: (x: number) => void + const asyncAtom = atom(() => { + return new Promise((resolve) => (resolveAsync = resolve)) + }) + + const { findByText, getByText } = render( + + + + ) + + getByText('Loading...') + resolveAsync(5) + await findByText('Data: 5') +}) + +it('loadable turns errors into values', async () => { + let rejectAsync!: (error: Error) => void + const asyncAtom = atom(() => { + return new Promise((resolve, reject) => (rejectAsync = reject)) + }) + + const { findByText, getByText } = render( + + + + ) + + getByText('Loading...') + rejectAsync(new Error('An error occurred')) + await findByText('Error: An error occurred') +}) + +it('loadable turns primitive throws into values', async () => { + let rejectAsync!: (errorMessage: string) => void + const asyncAtom = atom(() => { + return new Promise((resolve, reject) => (rejectAsync = reject)) + }) + + const { findByText, getByText } = render( + + + + ) + + getByText('Loading...') + rejectAsync('An error occurred') + await findByText('Error: An error occurred') +}) + +it('loadable goes back to loading after re-fetch', async () => { + let resolveAsync!: (x: number) => void + const refreshAtom = atom(0) + const asyncAtom = atom((get) => { + get(refreshAtom) + return new Promise((resolve) => (resolveAsync = resolve)) + }) + + const Refresh = () => { + const setRefresh = useUpdateAtom(refreshAtom) + return ( + <> + + + ) + } + + const { findByText, getByText } = render( + + + + + ) + + getByText('Loading...') + resolveAsync(5) + await findByText('Data: 5') + fireEvent.click(getByText('refresh')) + await findByText('Loading...') + resolveAsync(6) + await findByText('Data: 6') +}) + +it('loadable can recover from error', async () => { + let resolveAsync!: (x: number) => void + let rejectAsync!: (error: Error) => void + const refreshAtom = atom(0) + const asyncAtom = atom((get) => { + get(refreshAtom) + return new Promise((resolve, reject) => { + resolveAsync = resolve + rejectAsync = reject + }) + }) + + const Refresh = () => { + const setRefresh = useUpdateAtom(refreshAtom) + return ( + <> + + + ) + } + + const { findByText, getByText } = render( + + + + + ) + + getByText('Loading...') + rejectAsync(new Error('An error occurred')) + await findByText('Error: An error occurred') + fireEvent.click(getByText('refresh')) + await findByText('Loading...') + resolveAsync(6) + await findByText('Data: 6') +}) + +interface LoadableComponentProps { + asyncAtom: Atom +} + +const LoadableComponent = ({ asyncAtom }: LoadableComponentProps) => { + const value = useAtomValue(loadable(asyncAtom)) + + if (value.state === 'loading') { + return <>Loading... + } + + if (value.state === 'hasError') { + return <>{String(value.error)} + } + + return <>Data: {value.data} +}