repos / starfx

supercharged async flow control library.
git clone https://github.com/neurosnap/starfx.git

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}