Eric Bower
·
19 Aug 24
react.ts
1import {
2 Provider as ReduxProvider,
3 React,
4 useDispatch,
5 useSelector,
6 useStore as useReduxStore,
7} from "./deps.ts";
8import type { AnyState, LoaderState } from "./types.ts";
9import type { ThunkAction } from "./query/mod.ts";
10import { type FxSchema, type FxStore, PERSIST_LOADER_ID } from "./store/mod.ts";
11import { ActionFn, ActionFnWithPayload } from "./types.ts";
12import { getIdFromAction } from "./action.ts";
13
14export { useDispatch, useSelector } from "./deps.ts";
15export type { TypedUseSelectorHook } from "./deps.ts";
16
17const {
18 useContext,
19 useEffect,
20 useRef,
21 createContext,
22 createElement: h,
23} = React;
24
25export interface UseApiProps<P = any> extends LoaderState {
26 trigger: (p: P) => void;
27 action: ActionFnWithPayload<P>;
28}
29export interface UseApiSimpleProps extends LoaderState {
30 trigger: () => void;
31 action: ActionFnWithPayload;
32}
33export interface UseApiAction<A extends ThunkAction = ThunkAction>
34 extends LoaderState {
35 trigger: () => void;
36 action: A;
37}
38export type UseApiResult<P, A extends ThunkAction = ThunkAction> =
39 | UseApiProps<P>
40 | UseApiSimpleProps
41 | UseApiAction<A>;
42
43export interface UseCacheResult<D = any, A extends ThunkAction = ThunkAction>
44 extends UseApiAction<A> {
45 data: D | null;
46}
47
48const SchemaContext = createContext<FxSchema<any, any> | null>(null);
49
50export function Provider(
51 { store, schema, children }: {
52 store: FxStore<any>;
53 schema: FxSchema<any, any>;
54 children: React.ReactNode;
55 },
56) {
57 return (
58 h(ReduxProvider, {
59 store,
60 children: h(
61 SchemaContext.Provider,
62 { value: schema, children },
63 ) as any,
64 })
65 );
66}
67
68export function useSchema<S extends AnyState>() {
69 return useContext(SchemaContext) as FxSchema<S>;
70}
71
72export function useStore<S extends AnyState>() {
73 return useReduxStore() as FxStore<S>;
74}
75
76/**
77 * useLoader will take an action creator or action itself and return the associated
78 * loader for it.
79 *
80 * @returns the loader object for an action creator or action
81 *
82 * @example
83 * ```ts
84 * import { useLoader } from 'starfx/react';
85 *
86 * import { api } from './api';
87 *
88 * const fetchUsers = api.get('/users', function*() {
89 * // ...
90 * });
91 *
92 * const View = () => {
93 * const loader = useLoader(fetchUsers);
94 * // or: const loader = useLoader(fetchUsers());
95 * return <div>{loader.isLoader ? 'Loading ...' : 'Done!'}</div>
96 * }
97 * ```
98 */
99export function useLoader<S extends AnyState>(
100 action: ThunkAction | ActionFnWithPayload,
101) {
102 const schema = useSchema();
103 const id = getIdFromAction(action);
104 return useSelector((s: S) => schema.loaders.selectById(s, { id }));
105}
106
107/**
108 * useApi will take an action creator or action itself and fetch
109 * the associated loader and create a `trigger` function that you can call
110 * later in your react component.
111 *
112 * This hook will *not* fetch the data for you because it does not know how to fetch
113 * data from your redux state.
114 *
115 * @example
116 * ```ts
117 * import { useApi } from 'starfx/react';
118 *
119 * import { api } from './api';
120 *
121 * const fetchUsers = api.get('/users', function*() {
122 * // ...
123 * });
124 *
125 * const View = () => {
126 * const { isLoading, trigger } = useApi(fetchUsers);
127 * useEffect(() => {
128 * trigger();
129 * }, []);
130 * return <div>{isLoading ? : 'Loading' : 'Done!'}</div>
131 * }
132 * ```
133 */
134export function useApi<P = any, A extends ThunkAction = ThunkAction<P>>(
135 action: A,
136): UseApiAction<A>;
137export function useApi<P = any, A extends ThunkAction = ThunkAction<P>>(
138 action: ActionFnWithPayload<P>,
139): UseApiProps<P>;
140export function useApi<A extends ThunkAction = ThunkAction>(
141 action: ActionFn,
142): UseApiSimpleProps;
143export function useApi(action: any): any {
144 const dispatch = useDispatch();
145 const loader = useLoader(action);
146 const trigger = (p: any) => {
147 if (typeof action === "function") {
148 dispatch(action(p));
149 } else {
150 dispatch(action);
151 }
152 };
153 return { ...loader, trigger, action };
154}
155
156/**
157 * useQuery uses {@link useApi} and automatically calls `useApi().trigger()`
158 *
159 * @example
160 * ```ts
161 * import { useQuery } from 'starfx/react';
162 *
163 * import { api } from './api';
164 *
165 * const fetchUsers = api.get('/users', function*() {
166 * // ...
167 * });
168 *
169 * const View = () => {
170 * const { isLoading } = useQuery(fetchUsers);
171 * return <div>{isLoading ? : 'Loading' : 'Done!'}</div>
172 * }
173 * ```
174 */
175export function useQuery<P = any, A extends ThunkAction = ThunkAction<P>>(
176 action: A,
177): UseApiAction<A> {
178 const api = useApi(action);
179 useEffect(() => {
180 api.trigger();
181 }, [action.payload.key]);
182 return api;
183}
184
185/**
186 * useCache uses {@link useQuery} and automatically selects the cached data associated
187 * with the action creator or action provided.
188 *
189 * @example
190 * ```ts
191 * import { useCache } from 'starfx/react';
192 *
193 * import { api } from './api';
194 *
195 * const fetchUsers = api.get('/users', api.cache());
196 *
197 * const View = () => {
198 * const { isLoading, data } = useCache(fetchUsers());
199 * return <div>{isLoading ? : 'Loading' : data.length}</div>
200 * }
201 * ```
202 */
203export function useCache<P = any, ApiSuccess = any>(
204 action: ThunkAction<P, ApiSuccess>,
205): UseCacheResult<typeof action.payload._result, ThunkAction<P, ApiSuccess>> {
206 const schema = useSchema();
207 const id = action.payload.key;
208 const data: any = useSelector((s: any) => schema.cache.selectById(s, { id }));
209 const query = useQuery(action);
210 return { ...query, data: data || null };
211}
212
213/**
214 * useLoaderSuccess will activate the callback provided when the loader transitions
215 * from some state to success.
216 *
217 * @example
218 * ```ts
219 * import { useLoaderSuccess, useApi } from 'starfx/react';
220 *
221 * import { api } from './api';
222 *
223 * const createUser = api.post('/users', function*(ctx, next) {
224 * // ...
225 * });
226 *
227 * const View = () => {
228 * const { loader, trigger } = useApi(createUser);
229 * const onSubmit = () => {
230 * trigger({ name: 'bob' });
231 * };
232 *
233 * useLoaderSuccess(loader, () => {
234 * // success!
235 * // Use this callback to navigate to another view
236 * });
237 *
238 * return <button onClick={onSubmit}>Create user!</button>
239 * }
240 * ```
241 */
242export function useLoaderSuccess(
243 cur: Pick<LoaderState, "status">,
244 success: () => any,
245) {
246 const prev = useRef(cur);
247 useEffect(() => {
248 if (prev.current.status !== "success" && cur.status === "success") {
249 success();
250 }
251 prev.current = cur;
252 }, [cur.status]);
253}
254
255interface PersistGateProps {
256 children: React.ReactNode;
257 loading?: JSX.Element;
258}
259
260function Loading({ text }: { text: string }) {
261 return h("div", null, text);
262}
263
264export function PersistGate(
265 { children, loading = h(Loading) }: PersistGateProps,
266) {
267 const schema = useSchema();
268 const ldr = useSelector((s: any) =>
269 schema.loaders.selectById(s, { id: PERSIST_LOADER_ID })
270 );
271
272 if (ldr.status === "error") {
273 return h("div", null, ldr.message);
274 }
275
276 if (ldr.status !== "success") {
277 return loading;
278 }
279
280 return children;
281}