repos / starfx

a micro-mvc framework for react apps
git clone https://github.com/neurosnap/starfx.git

starfx / src
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}