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