| 1 | /** |
| 2 | * React helpers for creating Atom instances that belong to one component |
| 3 | * subtree. `make` returns a scoped atom with a provider, context, and `use` |
| 4 | * accessor. Each provider creates its own Atom once, so different subtrees can |
| 5 | * use the same scoped atom definition without sharing state. |
| 6 | * |
| 7 | * @since 4.0.0 |
| 8 | */ |
| 9 | "use client" |
| 10 | |
| 11 | import type * as Atom from "effect/reactivity/Atom" |
| 12 | import * as React from "react" |
| 13 | |
| 14 | /** |
| 15 | * Literal type used as the `ScopedAtom` type identifier. |
| 16 | * |
| 17 | * **Details** |
| 18 | * |
| 19 | * Used as the computed property key and marker value stored on `ScopedAtom` |
| 20 | * objects. |
| 21 | * |
| 22 | * @category type IDs |
| 23 | * @since 4.0.0 |
| 24 | */ |
| 25 | export type TypeId = "~@effect/atom-react/ScopedAtom" |
| 26 | |
| 27 | /** |
| 28 | * Type identifier for ScopedAtom. |
| 29 | * |
| 30 | * **Details** |
| 31 | * |
| 32 | * Used as the computed property key and marker value stored on `ScopedAtom` |
| 33 | * objects. |
| 34 | * |
| 35 | * @category type IDs |
| 36 | * @since 4.0.0 |
| 37 | */ |
| 38 | export const TypeId: TypeId = "~@effect/atom-react/ScopedAtom" |
| 39 | |
| 40 | /** |
| 41 | * Scoped Atom interface with a provider-backed instance. |
| 42 | * |
| 43 | * **Example** (Providing and reading a scoped atom) |
| 44 | * |
| 45 | * ```ts import.meta.vitest |
| 46 | * import { make, useAtomValue } from "@effect/atom-react" |
| 47 | * import { Atom } from "effect/reactivity" |
| 48 | * import * as React from "react" |
| 49 | * import { renderToStaticMarkup } from "react-dom/server" |
| 50 | * |
| 51 | * const Counter = make(() => Atom.make(0)) |
| 52 | * |
| 53 | * function View() { |
| 54 | * const atom = Counter.use() |
| 55 | * const value = useAtomValue(atom) |
| 56 | * return React.createElement("div", null, value) |
| 57 | * } |
| 58 | * |
| 59 | * export function App() { |
| 60 | * return React.createElement(Counter.Provider, null, React.createElement(View)) |
| 61 | * } |
| 62 | * |
| 63 | * renderToStaticMarkup(React.createElement(App)) // => "<div>0</div>" |
| 64 | * ``` |
| 65 | * |
| 66 | * @category models |
| 67 | * @since 4.0.0 |
| 68 | */ |
| 69 | export interface ScopedAtom<A extends Atom.Atom<any>, Input = never> { |
| 70 | readonly [TypeId]: TypeId |
| 71 | use(): A |
| 72 | Provider: [Input] extends [never] ? React.FC<{ readonly children?: React.ReactNode | undefined }> |
| 73 | : React.FC<{ readonly children?: React.ReactNode | undefined; readonly value: Input }> |
| 74 | Context: React.Context<A> |
| 75 | } |
| 76 | |
| 77 | /** |
| 78 | * Creates a ScopedAtom from a factory function. |
| 79 | * |
| 80 | * **When to use** |
| 81 | * |
| 82 | * Use to create an atom instance that is owned by a React provider and scoped |
| 83 | * to a component subtree. |
| 84 | * |
| 85 | * **Details** |
| 86 | * |
| 87 | * The returned scoped atom includes a `Provider`, `Context`, and `use` |
| 88 | * accessor. The provider creates the atom once for its lifetime, passing the |
| 89 | * `value` prop to the factory when the scoped atom expects input. |
| 90 | * |
| 91 | * **Gotchas** |
| 92 | * |
| 93 | * `use` must run under the matching provider. Changing the provider `value` |
| 94 | * prop after mount does not recreate the atom. |
| 95 | * |
| 96 | * **Example** (Creating a scoped atom with input) |
| 97 | * |
| 98 | * ```ts import.meta.vitest |
| 99 | * import { make, useAtomValue } from "@effect/atom-react" |
| 100 | * import { Atom } from "effect/reactivity" |
| 101 | * import * as React from "react" |
| 102 | * import { renderToStaticMarkup } from "react-dom/server" |
| 103 | * |
| 104 | * const User = make((name: string) => Atom.make(name)) |
| 105 | * |
| 106 | * function UserName() { |
| 107 | * const atom = User.use() |
| 108 | * const value = useAtomValue(atom) |
| 109 | * return React.createElement("span", null, value) |
| 110 | * } |
| 111 | * |
| 112 | * export function App() { |
| 113 | * return React.createElement( |
| 114 | * User.Provider, |
| 115 | * { value: "Ada" }, |
| 116 | * React.createElement(UserName) |
| 117 | * ) |
| 118 | * } |
| 119 | * |
| 120 | * renderToStaticMarkup(React.createElement(App)) // => "<span>Ada</span>" |
| 121 | * ``` |
| 122 | * |
| 123 | * @category constructors |
| 124 | * @since 4.0.0 |
| 125 | */ |
| 126 | export const make = <A extends Atom.Atom<any>, Input = never>( |
| 127 | f: (() => A) | ((input: Input) => A) |
| 128 | ): ScopedAtom<A, Input> => { |
| 129 | const Context = React.createContext<A>(undefined as unknown as A) |
| 130 | |
| 131 | const use = (): A => { |
| 132 | const atom = React.useContext(Context) |
| 133 | if (atom === undefined) { |
| 134 | throw new Error("ScopedAtom used outside of its Provider") |
| 135 | } |
| 136 | return atom |
| 137 | } |
| 138 | |
| 139 | const Provider: React.FC<{ readonly children?: React.ReactNode | undefined; readonly value?: Input }> = (props) => { |
| 140 | const atom = React.useRef<A | null>(null) |
| 141 | if (atom.current === null) { |
| 142 | if ("value" in props) { |
| 143 | atom.current = (f as (input: Input) => A)(props.value as Input) |
| 144 | } else { |
| 145 | atom.current = (f as () => A)() |
| 146 | } |
| 147 | } |
| 148 | return React.createElement(Context.Provider, { value: atom.current }, props.children) |
| 149 | } |
| 150 | |
| 151 | return { |
| 152 | [TypeId]: TypeId, |
| 153 | use, |
| 154 | Provider: Provider as any, |
| 155 | Context |
| 156 | } |
| 157 | } |