;
+ const Data = () => {
+ let loading: boolean;
+ [wrappedFunc, loading] = useLoading(fun);
+ return {loading ? 'loading' : 'loaded'}
;
+ };
+ const tree = ;
+ const { getByText } = render(tree);
+ expect(getByText(/loaded/i)).toBeDefined();
+ act(() => {
+ wrappedFunc('test string');
+ });
+ expect(getByText(/loading/i)).toBeDefined();
+ act(() => {
+ jest.advanceTimersByTime(1100);
+ });
+ await waitFor(() => expect(getByText(/loaded/i)).toBeDefined());
+ });
+
+ it('should maintain referential equality if function does', async () => {
+ function fun(value: string) {
+ return new Promise((resolve, reject) =>
+ setTimeout(() => resolve(value), 1000),
+ );
+ }
+ const { result, rerender } = renderHook(() => {
+ return useLoading(fun);
+ });
+ const [cb] = result.current;
+ rerender();
+ expect(result.current[0]).toBe(cb);
+ });
+
+ it('should maintain referential equality based on deps', async () => {
+ const { result, rerender } = renderHook(
+ ({ value }: { value: string }) => {
+ return useLoading(() => {
+ return new Promise((resolve, reject) =>
+ setTimeout(() => resolve(value), 1000),
+ );
+ }, [value]);
+ },
+ { initialProps: { value: 'a' } },
+ );
+ const [cb] = result.current;
+ rerender({ value: 'a' });
+ expect(result.current[0]).toBe(cb);
+ rerender({ value: 'b' });
+ expect(result.current[0]).not.toBe(cb);
+ });
+});
diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts
index bc74feb9d923..79fae94510bc 100644
--- a/packages/react/src/hooks/index.ts
+++ b/packages/react/src/hooks/index.ts
@@ -7,3 +7,6 @@ export { default as useSubscription } from './useSubscription.js';
export { default as useDLE } from './useDLE.js';
export { default as useController } from './useController.js';
export { default as useLive } from './useLive.js';
+export { default as useDebounce } from './useDebounce.js';
+export { default as useCancelling } from './useCancelling.js';
+export { default as useLoading } from './useLoading.js';
diff --git a/packages/react/src/hooks/useCancelling.ts b/packages/react/src/hooks/useCancelling.ts
new file mode 100644
index 000000000000..4a8646e8fa43
--- /dev/null
+++ b/packages/react/src/hooks/useCancelling.ts
@@ -0,0 +1,31 @@
+import type { EndpointInterface } from '@data-client/normalizr';
+import { useMemo, useRef } from 'react';
+
+/**
+ * Builds an Endpoint that cancels fetch everytime params change
+ *
+ * @see https://dataclient.io/docs/api/useCancelling
+ * @example
+ ```
+ useSuspense(useCancelling(MyEndpoint, { id }), { id })
+ ```
+ */
+export default function useCancelling<
+ E extends EndpointInterface & {
+ extend: (o: { signal?: AbortSignal }) => any;
+ },
+>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): E {
+ const abortRef = useRef();
+
+ // send abort signal anytime the params change
+ // if fetch is already completed signal goes nowhere
+ const key = args[0] !== null ? endpoint.key(...args) : '';
+ return useMemo(() => {
+ if (abortRef.current) abortRef.current.abort();
+ abortRef.current = new AbortController();
+ return endpoint.extend({
+ signal: abortRef.current.signal,
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [key]);
+}
diff --git a/packages/react/src/hooks/useDebounce.ts b/packages/react/src/hooks/useDebounce.ts
new file mode 100644
index 000000000000..8c45915553ae
--- /dev/null
+++ b/packages/react/src/hooks/useDebounce.ts
@@ -0,0 +1,35 @@
+import { useEffect, useState } from 'react';
+
+/**
+ * Keeps value updated after delay time
+ *
+ * @see https://dataclient.io/docs/api/useDebounce
+ * @param value Any immutable value
+ * @param delay Time in miliseconds to wait til updating the value
+ * @param updatable Whether to update at all
+ * @example
+ ```
+ const debouncedFilter = useDebounced(filter, 200);
+ const list = useSuspense(ListShape, { filter });
+ ```
+ */
+export default function useDebounce(
+ value: T,
+ delay: number,
+ updatable = true,
+) {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ if (!updatable) return;
+
+ const handler = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [value, delay, updatable]);
+
+ return debouncedValue;
+}
diff --git a/packages/react/src/hooks/useLoading.ts b/packages/react/src/hooks/useLoading.ts
new file mode 100644
index 000000000000..3ed23ee998fb
--- /dev/null
+++ b/packages/react/src/hooks/useLoading.ts
@@ -0,0 +1,51 @@
+import { useEffect, useState, useRef, useCallback } from 'react';
+
+/**
+ * Takes an async function and tracks resolution as a boolean.
+ *
+ * @see https://dataclient.io/docs/api/useLoading
+ * @param func A function returning a promise
+ * @param deps Deps list sent to useCallback()
+ * @example
+ ```
+ function Button({ onClick, children, ...props }) {
+ const [clickHandler, loading] = useLoading(onClick);
+ return (
+
+ );
+ }
+ ```
+ */
+export default function useLoading Promise>(
+ func: F,
+ deps?: readonly any[],
+): [F, boolean, Error | undefined] {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(undefined);
+ const isMountedRef = useRef(true);
+ useEffect(() => {
+ isMountedRef.current = true;
+ return () => {
+ isMountedRef.current = false;
+ };
+ }, []);
+ const depsList = deps || [func];
+ const wrappedFunc = useCallback(async (...args: any) => {
+ setLoading(true);
+ let ret;
+ try {
+ ret = await func(...args);
+ } catch (e: any) {
+ setError(e);
+ } finally {
+ if (isMountedRef.current) {
+ setLoading(false);
+ }
+ }
+ return ret;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, depsList);
+ return [wrappedFunc as any, loading, error];
+}
diff --git a/website/src/components/Playground/editor-types/@data-client/react.d.ts b/website/src/components/Playground/editor-types/@data-client/react.d.ts
index 26c5124fcf2b..da9ab61401a3 100644
--- a/website/src/components/Playground/editor-types/@data-client/react.d.ts
+++ b/website/src/components/Playground/editor-types/@data-client/react.d.ts
@@ -1,5 +1,5 @@
import * as _data_client_core from '@data-client/core';
-import { Manager, State, Controller, EndpointInterface, FetchFunction, Schema, ResolveType, Denormalize, DenormalizeNullable, Queryable, NI, SchemaArgs, NetworkError, UnknownError, ErrorTypes as ErrorTypes$1, __INTERNAL__, createReducer, applyManager } from '@data-client/core';
+import { Manager, State, Controller, EndpointInterface as EndpointInterface$1, FetchFunction as FetchFunction$1, Schema as Schema$1, ResolveType as ResolveType$1, Denormalize as Denormalize$1, DenormalizeNullable as DenormalizeNullable$1, Queryable as Queryable$1, NI, SchemaArgs, NetworkError as NetworkError$1, UnknownError as UnknownError$1, ErrorTypes as ErrorTypes$2, __INTERNAL__, createReducer, applyManager } from '@data-client/core';
export { AbstractInstanceType, ActionTypes, Controller, DataClientDispatch, DefaultConnectionListener, Denormalize, DenormalizeNullable, DevToolsManager, Dispatch, EndpointExtraOptions, EndpointInterface, ErrorTypes, ExpiryStatus, FetchAction, FetchFunction, GenericDispatch, InvalidateAction, LogoutManager, Manager, Middleware, MiddlewareAPI, NetworkError, NetworkManager, Normalize, NormalizeNullable, PK, PollingSubscription, ResetAction, ResolveType, Schema, SetAction, SetResponseAction, State, SubscribeAction, SubscriptionManager, UnknownError, UnsubscribeAction, UpdateFunction, actionTypes } from '@data-client/core';
import * as react_jsx_runtime from 'react/jsx-runtime';
import React, { JSX, Context } from 'react';
@@ -103,8 +103,8 @@ interface Props {
* @throws {Promise} If data is not yet available.
* @throws {NetworkError} If fetch fails.
*/
-declare function useSuspense>(endpoint: E, ...args: readonly [...Parameters]): E['schema'] extends undefined | null ? ResolveType : Denormalize;
-declare function useSuspense>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): E['schema'] extends undefined | null ? ResolveType | undefined : DenormalizeNullable;
+declare function useSuspense>(endpoint: E, ...args: readonly [...Parameters]): E['schema'] extends undefined | null ? ResolveType$1 : Denormalize$1;
+declare function useSuspense>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): E['schema'] extends undefined | null ? ResolveType$1 | undefined : DenormalizeNullable$1;
/**
* Access a response if it is available.
@@ -112,7 +112,7 @@ declare function useSuspense, 'key' | 'schema' | 'invalidIfStale'>>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): E['schema'] extends undefined | null ? E extends (...args: any) => any ? ResolveType | undefined : any : DenormalizeNullable;
+declare function useCache, 'key' | 'schema' | 'invalidIfStale'>>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): E['schema'] extends undefined | null ? E extends (...args: any) => any ? ResolveType$1 | undefined : any : DenormalizeNullable$1;
/**
* Query the store.
@@ -120,43 +120,43 @@ declare function useCache(schema: S, ...args: NI>): DenormalizeNullable | undefined;
+declare function useQuery(schema: S, ...args: NI>): DenormalizeNullable$1 | undefined;
-type ErrorTypes = NetworkError | UnknownError;
+type ErrorTypes$1 = NetworkError$1 | UnknownError$1;
/**
* Get any errors for a given request
* @see https://dataclient.io/docs/api/useError
*/
-declare function useError>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): ErrorTypes | undefined;
+declare function useError>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): ErrorTypes$1 | undefined;
/**
* Request a resource if it is not in cache.
* @see https://dataclient.io/docs/api/useFetch
*/
-declare function useFetch>(endpoint: E, ...args: readonly [...Parameters]): E['schema'] extends undefined | null ? ReturnType : Promise>;
-declare function useFetch>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): E['schema'] extends undefined | null ? ReturnType | undefined : Promise>;
+declare function useFetch>(endpoint: E, ...args: readonly [...Parameters]): E['schema'] extends undefined | null ? ReturnType : Promise>;
+declare function useFetch>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): E['schema'] extends undefined | null ? ReturnType | undefined : Promise>;
/**
* Keeps a resource fresh by subscribing to updates.
* @see https://dataclient.io/docs/api/useSubscription
*/
-declare function useSubscription>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): void;
+declare function useSubscription>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): void;
-type SchemaReturn = {
- data: Denormalize;
+type SchemaReturn = {
+ data: Denormalize$1;
loading: false;
error: undefined;
} | {
- data: DenormalizeNullable;
+ data: DenormalizeNullable$1;
loading: true;
error: undefined;
} | {
- data: DenormalizeNullable;
+ data: DenormalizeNullable$1;
loading: false;
- error: ErrorTypes$1;
+ error: ErrorTypes$2;
};
type AsyncReturn = {
- data: E extends (...args: any) => any ? ResolveType : any;
+ data: E extends (...args: any) => any ? ResolveType$1 : any;
loading: false;
error: undefined;
} | {
@@ -166,17 +166,17 @@ type AsyncReturn = {
} | {
data: undefined;
loading: false;
- error: ErrorTypes$1;
+ error: ErrorTypes$2;
};
/**
* Use async date with { data, loading, error } (DLE)
* @see https://dataclient.io/docs/api/useDLE
*/
-declare function useDLE>(endpoint: E, ...args: readonly [...Parameters]): E['schema'] extends undefined | null ? AsyncReturn : SchemaReturn;
-declare function useDLE>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): {
- data: E['schema'] extends undefined | null ? undefined : DenormalizeNullable;
+declare function useDLE>(endpoint: E, ...args: readonly [...Parameters]): E['schema'] extends undefined | null ? AsyncReturn : SchemaReturn;
+declare function useDLE>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): {
+ data: E['schema'] extends undefined | null ? undefined : DenormalizeNullable$1;
loading: boolean;
- error: ErrorTypes$1 | undefined;
+ error: ErrorTypes$2 | undefined;
};
/**
@@ -193,8 +193,191 @@ declare function useController(): Controller;
* @throws {Promise} If data is not yet available.
* @throws {NetworkError} If fetch fails.
*/
-declare function useLive>(endpoint: E, ...args: readonly [...Parameters]): E['schema'] extends undefined | null ? ResolveType : Denormalize;
-declare function useLive>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): E['schema'] extends undefined | null ? ResolveType | undefined : DenormalizeNullable;
+declare function useLive>(endpoint: E, ...args: readonly [...Parameters]): E['schema'] extends undefined | null ? ResolveType$1 : Denormalize$1;
+declare function useLive>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): E['schema'] extends undefined | null ? ResolveType$1 | undefined : DenormalizeNullable$1;
+
+/**
+ * Keeps value updated after delay time
+ *
+ * @see https://dataclient.io/docs/api/useDebounce
+ * @param value Any immutable value
+ * @param delay Time in miliseconds to wait til updating the value
+ * @param updatable Whether to update at all
+ * @example
+ ```
+ const debouncedFilter = useDebounced(filter, 200);
+ const list = useSuspense(ListShape, { filter });
+ ```
+ */
+declare function useDebounce(value: T, delay: number, updatable?: boolean): T;
+
+type Schema = null | string | {
+ [K: string]: any;
+} | Schema[] | SchemaSimple | Serializable;
+interface Queryable {
+ queryKey(args: readonly any[], queryKey: (...args: any) => any, getEntity: GetEntity, getIndex: GetIndex): {};
+}
+type Serializable = (value: any) => T;
+interface SchemaSimple {
+ normalize(input: any, parent: any, key: any, visit: (...args: any) => any, addEntity: (...args: any) => any, visitedEntities: Record, storeEntities: any, args: any[]): any;
+ denormalize(input: {}, args: readonly any[], unvisit: (input: any, schema: any) => any): T;
+ queryKey(args: Args, queryKey: (...args: any) => any, getEntity: GetEntity, getIndex: GetIndex): any;
+}
+interface EntityInterface extends SchemaSimple {
+ createIfValid(props: any): any;
+ pk(params: any, parent?: any, key?: string, args?: readonly any[]): string | number | undefined;
+ readonly key: string;
+ merge(existing: any, incoming: any): any;
+ mergeWithStore(existingMeta: any, incomingMeta: any, existing: any, incoming: any): any;
+ mergeMetaWithStore(existingMeta: any, incomingMeta: any, existing: any, incoming: any): any;
+ indexes?: any;
+ schema: Record;
+ cacheWith?: object;
+ prototype: T;
+}
+/** Get Array of entities with map function applied */
+interface GetEntity {
+ (entityKey: string): {
+ readonly [pk: string]: any;
+ } | undefined;
+ (entityKey: string, pk: string | number): any;
+}
+/** Get PK using an Entity Index */
+interface GetIndex {
+ /** getIndex('User', 'username', 'ntucker') */
+ (entityKey: string, field: string, value: string): {
+ readonly [indexKey: string]: string | undefined;
+ };
+}
+
+type AbstractInstanceType = T extends new (...args: any) => infer U ? U : T extends {
+ prototype: infer U;
+} ? U : never;
+type DenormalizeObject> = {
+ [K in keyof S]: S[K] extends Schema ? Denormalize : S[K];
+};
+type DenormalizeNullableObject> = {
+ [K in keyof S]: S[K] extends Schema ? DenormalizeNullable : S[K];
+};
+interface NestedSchemaClass {
+ schema: Record;
+ prototype: T;
+}
+interface RecordClass extends NestedSchemaClass {
+ fromJS: (...args: any) => AbstractInstanceType;
+}
+type DenormalizeNullableNestedSchema = keyof S['schema'] extends never ? S['prototype'] : string extends keyof S['schema'] ? S['prototype'] : S['prototype'];
+type Denormalize = S extends EntityInterface ? U : S extends RecordClass ? AbstractInstanceType : S extends {
+ denormalize: (...args: any) => any;
+} ? ReturnType : S extends Serializable ? T : S extends Array ? Denormalize[] : S extends {
+ [K: string]: any;
+} ? DenormalizeObject : S;
+type DenormalizeNullable = S extends EntityInterface ? DenormalizeNullableNestedSchema | undefined : S extends RecordClass ? DenormalizeNullableNestedSchema : S extends {
+ _denormalizeNullable: (...args: any) => any;
+} ? ReturnType : S extends Serializable ? T : S extends Array ? Denormalize[] | undefined : S extends {
+ [K: string]: any;
+} ? DenormalizeNullableObject : S;
+
+interface NetworkError extends Error {
+ status: number;
+ response?: Response;
+}
+interface UnknownError extends Error {
+ status?: unknown;
+ response?: unknown;
+}
+type ErrorTypes = NetworkError | UnknownError;
+
+/** What the function's promise resolves to */
+type ResolveType any> = ReturnType extends Promise ? R : never;
+
+type ExpiryStatusInterface = 1 | 2 | 3;
+
+interface SnapshotInterface {
+ /**
+ * Gets the (globally referentially stable) response for a given endpoint/args pair from state given.
+ * @see https://dataclient.io/docs/api/Snapshot#getResponse
+ */
+ getResponse>(endpoint: E, ...args: readonly any[]): {
+ data: DenormalizeNullable;
+ expiryStatus: ExpiryStatusInterface;
+ expiresAt: number;
+ };
+ /** @see https://dataclient.io/docs/api/Snapshot#getError */
+ getError: , Args extends readonly [...Parameters]>(endpoint: E, ...args: Args) => ErrorTypes | undefined;
+ /**
+ * Retrieved memoized value for any Querable schema
+ * @see https://dataclient.io/docs/api/Snapshot#get
+ */
+ get(schema: S, ...args: readonly any[]): any;
+ readonly fetchedAt: number;
+ readonly abort: Error;
+}
+
+/** Defines a networking endpoint */
+interface EndpointInterface extends EndpointExtraOptions {
+ (...args: Parameters): ReturnType;
+ key(...args: Parameters): string;
+ readonly sideEffect?: M;
+ readonly schema?: S;
+}
+interface EndpointExtraOptions {
+ /** Default data expiry length, will fall back to NetworkManager default if not defined */
+ readonly dataExpiryLength?: number;
+ /** Default error expiry length, will fall back to NetworkManager default if not defined */
+ readonly errorExpiryLength?: number;
+ /** Poll with at least this frequency in miliseconds */
+ readonly pollFrequency?: number;
+ /** Marks cached resources as invalid if they are stale */
+ readonly invalidIfStale?: boolean;
+ /** Enables optimistic updates for this request - uses return value as assumed network response */
+ getOptimisticResponse?(snap: SnapshotInterface, ...args: Parameters): ResolveType;
+ /** Determines whether to throw or fallback to */
+ errorPolicy?(error: any): 'hard' | 'soft' | undefined;
+ /** User-land extra data to send */
+ readonly extra?: any;
+}
+
+type FetchFunction = (...args: A) => Promise;
+
+/**
+ * Builds an Endpoint that cancels fetch everytime params change
+ *
+ * @see https://dataclient.io/docs/api/useCancelling
+ * @example
+ ```
+ useSuspense(useCancelling(MyEndpoint, { id }), { id })
+ ```
+ */
+declare function useCancelling any;
+}>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]): E;
+
+/**
+ * Takes an async function and tracks resolution as a boolean.
+ *
+ * @see https://dataclient.io/docs/api/useLoading
+ * @param func A function returning a promise
+ * @param deps Deps list sent to useCallback()
+ * @example
+ ```
+ function Button({ onClick, children, ...props }) {
+ const [clickHandler, loading] = useLoading(onClick);
+ return (
+
+ );
+ }
+ ```
+ */
+declare function useLoading Promise>(func: F, deps?: readonly any[]): [F, boolean, Error | undefined];
declare const StateContext: Context>;
declare const ControllerContext: Context>;
@@ -232,4 +415,4 @@ declare namespace internal_d {
/** Turns a dispatch function into one that resolves once its been commited */
declare function usePromisifiedDispatch>(dispatch: React.Dispatch>, state: React.ReducerState): (action: React.ReducerAction) => Promise;
-export { _default as AsyncBoundary, BackupLoading, DataProvider as CacheProvider, ControllerContext, DataProvider, DevToolsPosition, ErrorBoundary, ErrorBoundary as NetworkErrorBoundary, ProviderProps, StateContext, Store, StoreContext, UniversalSuspense, internal_d as __INTERNAL__, getDefaultManagers, useCache, useController, useDLE, useError, useFetch, useLive, usePromisifiedDispatch, useQuery, useSubscription, useSuspense };
+export { _default as AsyncBoundary, BackupLoading, DataProvider as CacheProvider, ControllerContext, DataProvider, DevToolsPosition, ErrorBoundary, ErrorBoundary as NetworkErrorBoundary, ProviderProps, StateContext, Store, StoreContext, UniversalSuspense, internal_d as __INTERNAL__, getDefaultManagers, useCache, useCancelling, useController, useDLE, useDebounce, useError, useFetch, useLive, useLoading, usePromisifiedDispatch, useQuery, useSubscription, useSuspense };