diff --git a/documentation/docs/core/cache.mdx b/documentation/docs/core/cache.mdx
index a5be52e9..f606e0f6 100644
--- a/documentation/docs/core/cache.mdx
+++ b/documentation/docs/core/cache.mdx
@@ -90,6 +90,8 @@ const client = createClient({
- **Event-driven**: Emits events on cache changes for real-time updates.
- **Persistence**: Easily enable persistent cache across sessions.
- **Fine-grained control**: Read, write, update, delete, and invalidate cache entries with simple methods.
+- **Structural sharing**: New data is merged with the previous entry - deeply equal parts keep their object references,
+ so an identical response keeps the same `data` reference. Disable it with `request.setStructuralSharing(false)`.
- **Integration**: Works seamlessly with the Client and Request systems.
---
diff --git a/documentation/docs/core/dispatcher.mdx b/documentation/docs/core/dispatcher.mdx
index 296ccab4..ce3a2b75 100644
--- a/documentation/docs/core/dispatcher.mdx
+++ b/documentation/docs/core/dispatcher.mdx
@@ -504,4 +504,18 @@ const queueMonitor = new Plugin({ name: "queue-monitor" })
client.addPlugin(queueMonitor);
```
+To observe the requests that are currently **in flight**, subscribe to the running requests events on the dispatcher.
+They fire whenever a request starts or stops running - finished, canceled or cleared:
+
+```ts
+const unmount = client.fetchDispatcher.events.onRunningRequestsChange(({ queryKey, runningRequests }) => {
+ console.log(`${queryKey} has ${runningRequests.length} running requests`);
+});
+
+// Or for a single queue
+client.fetchDispatcher.events.onRunningRequestsChangeByKey(getUsers.queryKey, console.log);
+```
+
+In React, the [`useIsFetching` and `useIsSubmitting`](/docs/react/hooks/use-is-fetching) hooks build on these events.
+
---
diff --git a/documentation/docs/core/request.mdx b/documentation/docs/core/request.mdx
index 5f6b99d4..d642fb08 100644
--- a/documentation/docs/core/request.mdx
+++ b/documentation/docs/core/request.mdx
@@ -394,6 +394,7 @@ When creating a request, the following defaults are applied unless overridden:
| `offline` | `true` | Whether requests are stored when offline |
| `deduplicate` | `false` | Whether identical in-flight requests are deduplicated |
| `deduplicateTime` | `null` | Custom deduplication window (uses request duration when `null`) |
+| `structuralSharing` | `true` | Keep references of the unchanged parts of the data when saving responses to cache |
---
diff --git a/documentation/docs/react/04-hooks/use-cache.mdx b/documentation/docs/react/04-hooks/use-cache.mdx
index 92956c60..50130c60 100644
--- a/documentation/docs/react/04-hooks/use-cache.mdx
+++ b/documentation/docs/react/04-hooks/use-cache.mdx
@@ -132,6 +132,25 @@ function UsersCache() {
---
+## Selecting Data
+
+Use the `select` option to subscribe to a part of the cached data. The hook re-renders only when the selected value
+changes, and the result keeps its reference while it stays deeply equal (structural sharing). See
+[useFetch - Selecting Data](/docs/react/hooks/use-fetch#selecting-data) for details.
+
+```tsx title="Cache selector"
+function TodosCounter() {
+ // Re-renders only when the number of done todos changes
+ const { data: doneCount } = useCache(getTodos, {
+ select: (todos) => todos.filter((todo) => todo.done).length,
+ });
+
+ return {doneCount} ;
+}
+```
+
+---
+
## Options
Configuration options for this hook provided as a second parameter.
diff --git a/documentation/docs/react/04-hooks/use-fetch.mdx b/documentation/docs/react/04-hooks/use-fetch.mdx
index f930df93..d18b85e3 100644
--- a/documentation/docs/react/04-hooks/use-fetch.mdx
+++ b/documentation/docs/react/04-hooks/use-fetch.mdx
@@ -245,13 +245,54 @@ function App() {
:::info React 18+ Required
-Suspense mode requires React 18 or later. You can combine it with `` components to handle error states
-declaratively.
+Suspense mode requires React 18 or later. You can combine it with `` components and the
+[`throwOnError`](#error-boundaries) option to handle error states declaratively.
:::
---
+## Error Boundaries
+
+Set `throwOnError: true` to throw the request error to the closest React
+[Error Boundary](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary) instead of
+returning it in the `error` field. The error is thrown only once the request settles - attempts that will be retried,
+canceled requests and offline (re-queued) requests never reach the boundary.
+
+```tsx title="Throwing errors to the boundary"
+import { ErrorBoundary } from "react-error-boundary";
+import { useFetch } from "@hyper-fetch/react";
+import { getUsers } from "./api/users";
+
+function UsersList() {
+ // highlight-next-line
+ const { data } = useFetch(getUsers, { suspense: true, throwOnError: true });
+
+ return ;
+}
+
+function App() {
+ return (
+ Retry }>
+ Loading users...
}>
+
+
+
+ );
+}
+```
+
+Errors are not stored in the cache, so resetting the boundary remounts the component and fetches the data again. Pass a
+function to decide per error - for example handle validation errors in the component and throw all the others:
+
+```tsx
+const { data, error } = useFetch(getUsers, {
+ throwOnError: (error) => error.code !== "VALIDATION_ERROR",
+});
+```
+
+---
+
## Keep Previous Data
By default, when the cache key changes (e.g. switching from one resource to another), `useFetch` clears the previous
@@ -313,6 +354,62 @@ are used. This can be useful for debugging or when you need to ensure the compon
---
+## Selecting Data
+
+Many components often need only a small part of the same response. The `select` option picks (or transforms) the data
+the component uses, while the request and its cache stay shared between all the hooks. The component re-renders only
+when **the selected value** changes - updates of the other parts of the response are skipped.
+
+```tsx title="Subscribing to a part of the response"
+const useSymbol = (id: string) => {
+ // highlight-next-line
+ return useFetch(getSymbols, { select: (symbols) => symbols[id] });
+};
+
+const useSymbolPrice = (id: string) => {
+ // highlight-next-line
+ return useFetch(getSymbols, { select: (symbols) => symbols[id]?.price });
+};
+
+function Price({ id }: { id: string }) {
+ // Re-renders only when this symbol's price changes, even though all symbols are fetched and cached together
+ const { data: price } = useSymbolPrice(id);
+ return {price} ;
+}
+```
+
+- The selector receives the response data and runs only when there is data - `data` stays `null` otherwise.
+- The selector can close over props. When it changes between renders (e.g. a new `id`), it is applied to the current
+ data right away, without waiting for a new response.
+- Inline selectors are fine - the result is [structurally shared](#structural-sharing), so a recreated but equal object
+ keeps its previous reference.
+- `data` and `setData` use the selected type. Event handlers like `onSuccess` and the cache keep the full response.
+- An error thrown by the selector is passed to the closest Error Boundary.
+
+---
+
+## Structural Sharing
+
+Every response saved to the cache is merged with the previous one: all the parts that are deeply equal keep their
+previous object references. When the same data comes back from a refetch, `data` keeps the exact same reference, and
+when only a part changed, the untouched branches are reused. Thanks to this `useMemo`, `useEffect` and `React.memo`
+dependencies stay stable without any extra work. The same is applied to the `select` results inside the hook.
+
+```tsx
+const { data } = useFetch(getUsers);
+
+useEffect(() => {
+ // Runs only when the users data really changed - not on every refetch
+ syncUsers(data);
+}, [data]);
+```
+
+Structural sharing works on plain objects and arrays - class instances, dates, maps etc. are compared by reference. You
+can disable it per request with `request.setStructuralSharing(false)` (or the `structuralSharing` request option), and
+for the selected data with the `structuralSharing: false` hook option.
+
+---
+
## Clear State
The `clearState` method resets all hook state (`data`, `error`, `status`, `extra`, timestamps) back to initial values.
@@ -378,6 +475,9 @@ const { ... } = useFetch(request, {
| `bounceType` | `"debounce"` | Bounce strategy: `"debounce"` or `"throttle"` |
| `bounceTime` | `400` | Bounce delay in ms |
| `deepCompare` | `true` | Deep compare options for memoization |
+| `select` | `undefined` | Pick or transform the part of the data used by the component |
+| `structuralSharing` | `true` | Keep references of the unchanged parts of the selected data |
+| `throwOnError` | `false` | Throw settled request errors to the closest Error Boundary |
:::info Refetch restarts the refresh timer
diff --git a/documentation/docs/react/04-hooks/use-is-fetching.mdx b/documentation/docs/react/04-hooks/use-is-fetching.mdx
new file mode 100644
index 00000000..c6248030
--- /dev/null
+++ b/documentation/docs/react/04-hooks/use-is-fetching.mdx
@@ -0,0 +1,65 @@
+---
+sidebar_position: 8
+title: React hooks - useIsFetching
+sidebar_label: useIsFetching
+---
+
+# useIsFetching
+
+[Read the API Reference ยป](/api/react/Hooks/useIsFetching.mdx)
+
+**`useIsFetching`** returns the number of requests currently running in the fetch dispatcher of a
+[`Client`](/core/client.mdx) - the ones triggered by `useFetch`, `useCache` or `client.fetchDispatcher`.
+**`useIsSubmitting`** does the same for the submit dispatcher (`useSubmit`).
+
+---
+
+:::tip Purpose
+
+1. Global loading and saving indicators
+2. Counting the in-flight requests of a part of the app
+
+:::
+
+---
+
+## Usage
+
+```tsx title="Global loading indicator"
+import { useIsFetching, useIsSubmitting } from "@hyper-fetch/react";
+import { client } from "./api/client";
+
+function GlobalSpinner() {
+ const fetching = useIsFetching(client);
+ const submitting = useIsSubmitting(client);
+
+ if (!fetching && !submitting) return null;
+ return ;
+}
+```
+
+---
+
+## Filtering
+
+Count only the requests you are interested in with the `queryKey` (exact string or RegExp) or a `predicate`.
+
+```tsx
+// Requests of one query
+const usersFetching = useIsFetching(client, { queryKey: getUsers.queryKey });
+
+// Requests matching a pattern
+const adminFetching = useIsFetching(client, { queryKey: /admin/ });
+
+// Any custom condition
+const uploading = useIsSubmitting(client, { predicate: (request) => request.endpoint.startsWith("/files") });
+```
+
+The count includes only the requests that are being sent at the moment - queued requests waiting for their turn and
+requests waiting for the network to come back are not counted. The hooks re-render only when the count changes.
+
+---
+
+## Options
+
+(@import react UseIsFetchingOptionsType type=returns)
diff --git a/documentation/docs/react/04-hooks/use-submit.mdx b/documentation/docs/react/04-hooks/use-submit.mdx
index 0116bea6..16eef8b5 100644
--- a/documentation/docs/react/04-hooks/use-submit.mdx
+++ b/documentation/docs/react/04-hooks/use-submit.mdx
@@ -317,6 +317,19 @@ function CreateUserForm() {
---
+## Selecting Data
+
+The `select` option picks the part of the response kept in the hook's `data`. The promise returned by `submit()` and
+the event handlers like `onSubmitSuccess` still receive the full response.
+
+```tsx title="Submit selector"
+const { submit, data: userName } = useSubmit(login, {
+ select: (response) => response.user.name,
+});
+```
+
+---
+
## Options
Customize the behavior of `useSubmit` by passing an options object as the second argument.
diff --git a/documentation/docs/react/05-sockets/use-listener.mdx b/documentation/docs/react/05-sockets/use-listener.mdx
index 045b5300..40e3297d 100644
--- a/documentation/docs/react/05-sockets/use-listener.mdx
+++ b/documentation/docs/react/05-sockets/use-listener.mdx
@@ -200,6 +200,35 @@ define them at module level, or the hook will re-subscribe on every render. See
---
+## Selecting Data
+
+When many components listen to the same topic but need only a part of each message, use the `select` option. The
+`data` value updates only when the selected value changes - messages that don't affect it don't re-render the
+component. The selected result is structurally shared, so an equal object recreated by the selector keeps its previous
+reference.
+
+```tsx title="Subscribing to a single symbol"
+const useSymbolPrice = (id: string) => {
+ // highlight-next-line
+ return useListener(onPrices, { select: (prices) => prices[id]?.price });
+};
+
+function Price({ id }: { id: string }) {
+ // Re-renders only when this symbol's price changes
+ const { data: price } = useSymbolPrice(id);
+ return {price} ;
+}
+```
+
+- `onEvent` still receives the full message.
+- Without `select`, every message updates `data`, even when it is equal to the previous one - messages are events.
+- The selector can close over props - when it changes (e.g. a new `id`), it is applied to the last message right away.
+- An error thrown by the selector is passed to the closest Error Boundary.
+- Read only the fields you render - the hook re-renders only for the accessed values (`dependencyTracking`, enabled by
+ default), so reading `timestamp` or `extra` makes the component re-render on every message again.
+
+---
+
## Options
Configuration options for `useListener` must be provided as the second parameter.
diff --git a/packages/core/__tests__/features/cache/cache.base.spec.ts b/packages/core/__tests__/features/cache/cache.base.spec.ts
index 933802ae..56604c4b 100644
--- a/packages/core/__tests__/features/cache/cache.base.spec.ts
+++ b/packages/core/__tests__/features/cache/cache.base.spec.ts
@@ -202,6 +202,54 @@ describe("Cache [ Base ]", () => {
});
});
+ describe("When structural sharing is used", () => {
+ it("should keep the data reference for deeply equal responses", async () => {
+ const spy = vi.fn();
+ cache.events.onDataByKey(request.cacheKey, spy);
+
+ cache.set(request, { ...response, ...details, data: { list: [{ id: 1 }], meta: { page: 1 } } });
+ const first = cache.get(request.cacheKey)?.data;
+ cache.set(request, { ...response, ...details, data: { list: [{ id: 1 }], meta: { page: 1 } } });
+ const second = cache.get(request.cacheKey)?.data;
+
+ expect(second).toBe(first);
+ expect(spy).toHaveBeenCalledTimes(2);
+ expect(spy.mock.calls[1][0].data).toBe(first);
+ });
+
+ it("should reuse unchanged parts of the data", async () => {
+ cache.set(request, { ...response, ...details, data: { list: [{ id: 1 }], meta: { page: 1 } } });
+ const first = cache.get(request.cacheKey)?.data;
+ cache.set(request, { ...response, ...details, data: { list: [{ id: 1 }], meta: { page: 2 } } });
+ const second = cache.get(request.cacheKey)?.data;
+
+ expect(second).not.toBe(first);
+ expect(second.list).toBe(first.list);
+ expect(second.meta).toStrictEqual({ page: 2 });
+ });
+
+ it("should share references for cache updates", async () => {
+ cache.set(request, { ...response, ...details, data: { list: [{ id: 1 }], meta: { page: 1 } } });
+ const first = cache.get(request.cacheKey)?.data;
+ cache.update(request, (prev) => ({ ...prev, data: { ...prev?.data, meta: { page: 3 } } }));
+ const second = cache.get(request.cacheKey)?.data;
+
+ expect(second.list).toBe(first.list);
+ expect(second.meta).toStrictEqual({ page: 3 });
+ });
+
+ it("should allow to disable structural sharing", async () => {
+ const disabledRequest = request.setStructuralSharing(false);
+ const data = { list: [{ id: 1 }] };
+ const newData = { list: [{ id: 1 }] };
+
+ cache.set(disabledRequest, { ...response, ...details, data });
+ cache.set(disabledRequest, { ...response, ...details, data: newData });
+
+ expect(cache.get(request.cacheKey)?.data).toBe(newData);
+ });
+ });
+
describe("When CacheStore gets cleared before triggering cache actions", () => {
it("should return undefined when removed cache entity", async () => {
const trigger = vi.fn();
diff --git a/packages/core/__tests__/features/cache/cache.replace-equal-deep.spec.ts b/packages/core/__tests__/features/cache/cache.replace-equal-deep.spec.ts
new file mode 100644
index 00000000..07ffdbea
--- /dev/null
+++ b/packages/core/__tests__/features/cache/cache.replace-equal-deep.spec.ts
@@ -0,0 +1,125 @@
+import { isPlainObject, replaceEqualDeep } from "cache";
+
+class Model {
+ value = 1;
+}
+
+describe("replaceEqualDeep [ Utils ]", () => {
+ describe("When values are equal", () => {
+ it("should return previous reference for deeply equal objects", () => {
+ const prev = { a: 1, b: { c: [1, 2, { d: "x" }] } };
+ const next = { a: 1, b: { c: [1, 2, { d: "x" }] } };
+
+ expect(replaceEqualDeep(prev, next)).toBe(prev);
+ });
+
+ it("should return previous reference for deeply equal arrays", () => {
+ const prev = [{ id: 1 }, { id: 2 }];
+ const next = [{ id: 1 }, { id: 2 }];
+
+ expect(replaceEqualDeep(prev, next)).toBe(prev);
+ });
+
+ it("should handle primitives and NaN", () => {
+ expect(replaceEqualDeep(1, 1)).toBe(1);
+ expect(replaceEqualDeep("a", "b")).toBe("b");
+ expect(replaceEqualDeep(NaN, NaN)).toBeNaN();
+ expect(replaceEqualDeep(null, { a: 1 })).toStrictEqual({ a: 1 });
+ expect(replaceEqualDeep({ a: 1 }, null)).toBeNull();
+ });
+
+ it("should treat empty containers as equal", () => {
+ const prevObject = {};
+ const prevArray: unknown[] = [];
+
+ expect(replaceEqualDeep(prevObject, {})).toBe(prevObject);
+ expect(replaceEqualDeep(prevArray, [])).toBe(prevArray);
+ });
+ });
+
+ describe("When values differ", () => {
+ it("should reuse unchanged branches of objects", () => {
+ const prev = { unchanged: { deep: [1, 2] }, changed: { value: 1 } };
+ const next = { unchanged: { deep: [1, 2] }, changed: { value: 2 } };
+
+ const result = replaceEqualDeep(prev, next);
+
+ expect(result).not.toBe(prev);
+ expect(result).toStrictEqual(next);
+ expect(result.unchanged).toBe(prev.unchanged);
+ expect(result.changed).not.toBe(prev.changed);
+ });
+
+ it("should reuse unchanged array items", () => {
+ const prev = [{ id: 1 }, { id: 2 }];
+ const next = [{ id: 1 }, { id: 3 }, { id: 4 }];
+
+ const result = replaceEqualDeep(prev, next);
+
+ expect(result).toStrictEqual(next);
+ expect(result[0]).toBe(prev[0]);
+ expect(result[1]).not.toBe(prev[1]);
+ });
+
+ it("should detect removed array items and object keys", () => {
+ const prevArray = [1, 2, 3];
+ const prevObject = { a: 1, b: 2 };
+
+ expect(replaceEqualDeep(prevArray, [1, 2])).toStrictEqual([1, 2]);
+ expect(replaceEqualDeep(prevArray, [1, 2])).not.toBe(prevArray);
+ expect(replaceEqualDeep(prevObject, { a: 1 })).toStrictEqual({ a: 1 });
+ expect(replaceEqualDeep(prevObject, { a: 1 })).not.toBe(prevObject);
+ });
+
+ it("should not treat a missing key as equal to an undefined value", () => {
+ const prev: Record = { a: 1, b: 2 };
+ const next: Record = { a: 1, c: undefined };
+
+ const result = replaceEqualDeep(prev, next);
+
+ expect(result).not.toBe(prev);
+ expect(Object.keys(result)).toStrictEqual(["a", "c"]);
+ });
+
+ it("should not mix arrays with objects", () => {
+ const prev = { 0: "a" };
+ const next = ["a"];
+
+ expect(replaceEqualDeep(prev, next)).toBe(next);
+ });
+
+ it("should replace non plain objects", () => {
+ const prevDate = new Date(1000);
+ const nextDate = new Date(1000);
+ const nextModel = new Model();
+ const nextMap = new Map([["a", 1]]);
+
+ expect(replaceEqualDeep(prevDate, nextDate)).toBe(nextDate);
+ expect(replaceEqualDeep(new Model(), nextModel)).toBe(nextModel);
+ expect(replaceEqualDeep(new Map([["a", 1]]), nextMap)).toBe(nextMap);
+ });
+
+ it("should keep shared references inside non plain parents", () => {
+ const shared = { a: 1 };
+ const prev = { list: [shared], date: new Date(1) };
+ const next = { list: [{ a: 1 }], date: new Date(1) };
+
+ const result = replaceEqualDeep(prev, next);
+
+ expect(result).not.toBe(prev);
+ expect(result.list).toBe(prev.list);
+ expect(result.date).toBe(next.date);
+ });
+ });
+
+ describe("When checking plain objects", () => {
+ it("should recognize plain objects only", () => {
+ expect(isPlainObject({})).toBeTrue();
+ expect(isPlainObject(Object.create(null))).toBeTrue();
+ expect(isPlainObject([])).toBeFalse();
+ expect(isPlainObject(new Date())).toBeFalse();
+ expect(isPlainObject(null)).toBeFalse();
+ expect(isPlainObject(new Model())).toBeFalse();
+ });
+ });
+});
diff --git a/packages/core/__tests__/features/dispatcher/dispatcher.events.spec.ts b/packages/core/__tests__/features/dispatcher/dispatcher.events.spec.ts
index afd1cf17..b0714a00 100644
--- a/packages/core/__tests__/features/dispatcher/dispatcher.events.spec.ts
+++ b/packages/core/__tests__/features/dispatcher/dispatcher.events.spec.ts
@@ -306,4 +306,56 @@ describe("Dispatcher [ Events ]", () => {
expect(spy).toHaveBeenCalledTimes(1);
});
});
+
+ describe("When running requests change", () => {
+ it("should emit running requests change when request starts and finishes", async () => {
+ const spy = vi.fn();
+ const keySpy = vi.fn();
+ const unmount = dispatcher.events.onRunningRequestsChange(spy);
+ const unmountKey = dispatcher.events.onRunningRequestsChangeByKey(request.queryKey, keySpy);
+
+ dispatcher.add(request);
+
+ expect(spy).toHaveBeenCalledTimes(1);
+ expect(spy.mock.calls[0][0].queryKey).toBe(request.queryKey);
+ expect(spy.mock.calls[0][0].runningRequests).toHaveLength(1);
+
+ await waitFor(() => {
+ expect(spy).toHaveBeenCalledTimes(2);
+ });
+ expect(spy.mock.calls[1][0].runningRequests).toHaveLength(0);
+ expect(keySpy).toHaveBeenCalledTimes(2);
+
+ unmount();
+ unmountKey();
+ });
+
+ it("should emit running requests change on cancel and clear", async () => {
+ const spy = vi.fn();
+ const unmount = dispatcher.events.onRunningRequestsChange(spy);
+ const otherRequest = request.setQueryKey("other");
+ mockRequest(request, { delay: 100 });
+ mockRequest(otherRequest, { delay: 100 });
+
+ const requestId = dispatcher.add(request);
+ dispatcher.add(otherRequest);
+ expect(dispatcher.getAllRunningRequests()).toHaveLength(2);
+ expect(spy).toHaveBeenCalledTimes(2);
+
+ dispatcher.cancelRunningRequest(request.queryKey, requestId);
+ expect(spy).toHaveBeenCalledTimes(3);
+ expect(spy.mock.calls[2][0].runningRequests).toHaveLength(0);
+
+ dispatcher.clear();
+ expect(dispatcher.getAllRunningRequests()).toHaveLength(0);
+ expect(spy.mock.calls.at(-1)?.[0]).toStrictEqual({ queryKey: otherRequest.queryKey, runningRequests: [] });
+
+ // No change - no event
+ const calls = spy.mock.calls.length;
+ dispatcher.deleteRunningRequests(request.queryKey);
+ expect(spy).toHaveBeenCalledTimes(calls);
+
+ unmount();
+ });
+ });
});
diff --git a/packages/core/__tests__/features/request/request.setters.spec.ts b/packages/core/__tests__/features/request/request.setters.spec.ts
index bed6eced..55ac5847 100644
--- a/packages/core/__tests__/features/request/request.setters.spec.ts
+++ b/packages/core/__tests__/features/request/request.setters.spec.ts
@@ -115,6 +115,13 @@ describe("Request [ Setters ]", () => {
const updatedRequest = request.setDeduplicateTime(1000);
expect(updatedRequest.deduplicateTime).toBe(1000);
});
+ it("should allow for setting structural sharing", async () => {
+ expect(request.structuralSharing).toBeTrue();
+ const updatedRequest = request.setStructuralSharing(false);
+ expect(updatedRequest.structuralSharing).toBeFalse();
+ expect(updatedRequest.clone().structuralSharing).toBeFalse();
+ expect(updatedRequest.toJSON().structuralSharing).toBeFalse();
+ });
it("should allow for setting used", async () => {
expect(request.used).toBeFalse();
const updatedRequest = request.setUsed(true);
diff --git a/packages/core/src/cache/cache.ts b/packages/core/src/cache/cache.ts
index f70aa390..351eaede 100644
--- a/packages/core/src/cache/cache.ts
+++ b/packages/core/src/cache/cache.ts
@@ -7,7 +7,7 @@ import type {
CacheSetState,
RequestCacheType,
} from "cache";
-import { getCacheData, getCacheEvents } from "cache";
+import { getCacheData, getCacheEvents, replaceEqualDeep } from "cache";
import type { ClientInstance } from "client";
import type { ResponseDetailsType, LoggerMethods } from "managers";
import type { RequestInstance } from "request";
@@ -86,6 +86,11 @@ export class Cache {
const processedResponse = typeof response === "function" ? response(previousCacheData || null) : response;
const data = getCacheData(previousCacheData, processedResponse);
+ // Keep references of the unchanged data parts, so deeply equal responses don't produce new objects
+ if (request.structuralSharing !== false && previousCacheData) {
+ data.data = replaceEqualDeep(previousCacheData.data, data.data);
+ }
+
const newCacheData: CacheValueType> = {
...data,
scope,
diff --git a/packages/core/src/cache/cache.types.ts b/packages/core/src/cache/cache.types.ts
index c2903955..3803dfc6 100644
--- a/packages/core/src/cache/cache.types.ts
+++ b/packages/core/src/cache/cache.types.ts
@@ -68,4 +68,4 @@ export type CacheSetState = CacheData | ((previousData: CacheData | n
export type RequestCacheType = Pick<
R,
"cacheKey" | "cache" | "staleTime" | "cacheTime" | "scope"
->;
+> & { structuralSharing?: boolean };
diff --git a/packages/core/src/cache/cache.utils.ts b/packages/core/src/cache/cache.utils.ts
index eaa59d0d..de357135 100644
--- a/packages/core/src/cache/cache.utils.ts
+++ b/packages/core/src/cache/cache.utils.ts
@@ -32,3 +32,59 @@ export const getCacheByKey = (key: string): string => {
export const getCacheKey = (): string => {
return `cache`;
};
+
+/**
+ * Check if value is a plain object (object literal or `Object.create(null)`).
+ * Class instances, Dates, Maps, Blobs etc. are not plain objects.
+ */
+export const isPlainObject = (value: unknown): value is Record => {
+ if (Object.prototype.toString.call(value) !== "[object Object]") {
+ return false;
+ }
+ const prototype = Object.getPrototypeOf(value);
+ return prototype === null || prototype === Object.prototype;
+};
+
+const isPlainArray = (value: unknown): value is unknown[] => {
+ return Array.isArray(value) && Object.keys(value).length === value.length;
+};
+
+/**
+ * Structural sharing - returns `next` with every part that is deeply equal to `prev` replaced by the `prev` reference.
+ * When both values are deeply equal the `prev` reference is returned, so consumers can rely on `===` checks.
+ * Only plain objects and arrays are traversed, other values are compared with `Object.is`.
+ * @param prev previous value
+ * @param next incoming value
+ * @returns `next` sharing unchanged references with `prev`
+ */
+export const replaceEqualDeep = (prev: unknown, next: T): T => {
+ if (Object.is(prev, next)) {
+ return prev as T;
+ }
+
+ const isArray = isPlainArray(prev) && isPlainArray(next);
+ if (!isArray && !(isPlainObject(prev) && isPlainObject(next))) {
+ return next;
+ }
+
+ const prevItems = prev as Record;
+ const nextItems = next as Record;
+ const prevKeys = Object.keys(prevItems);
+ const nextKeys = Object.keys(nextItems);
+ const copy: Record = isArray ? ([] as unknown as Record) : {};
+
+ let equalItems = 0;
+ nextKeys.forEach((key) => {
+ const prevValue = prevItems[key];
+ const nextValue = nextItems[key];
+ const value = replaceEqualDeep(prevValue, nextValue);
+ copy[key] = value;
+ // undefined values are equal only when the key existed before
+ // eslint-disable-next-line prefer-object-has-own -- Object.hasOwn is not available in older runtimes
+ if (value === prevValue && (value !== undefined || Object.prototype.hasOwnProperty.call(prevItems, key))) {
+ equalItems += 1;
+ }
+ });
+
+ return (prevKeys.length === nextKeys.length && equalItems === prevKeys.length ? prev : copy) as T;
+};
diff --git a/packages/core/src/dispatcher/dispatcher.events.ts b/packages/core/src/dispatcher/dispatcher.events.ts
index 1722ad07..0a637d80 100644
--- a/packages/core/src/dispatcher/dispatcher.events.ts
+++ b/packages/core/src/dispatcher/dispatcher.events.ts
@@ -1,4 +1,4 @@
-import type { QueueDataType } from "dispatcher";
+import type { QueueDataType, RunningRequestsChangeType } from "dispatcher";
import {
getDispatcherChangeKey,
getDispatcherChangeByKey,
@@ -6,6 +6,8 @@ import {
getDispatcherStatusByKey,
getDispatcherDrainedKey,
getDispatcherDrainedByKey,
+ getDispatcherRunningKey,
+ getDispatcherRunningByKey,
} from "dispatcher";
import type EventEmitter from "events";
import type { RequestInstance } from "request";
@@ -32,6 +34,10 @@ export const getDispatcherEvents = (emitter: EventEmitter) => ({
emitter.emit(getDispatcherChangeKey(), values, isTriggeredExternally);
emitter.emit(getDispatcherChangeByKey(values.queryKey), values, isTriggeredExternally);
},
+ emitRunningRequestsChanged: (values: RunningRequestsChangeType, isTriggeredExternally?: boolean): void => {
+ emitter.emit(getDispatcherRunningKey(), values, isTriggeredExternally);
+ emitter.emit(getDispatcherRunningByKey(values.queryKey), values, isTriggeredExternally);
+ },
/**
* When queue becomes empty
* @param callback
@@ -103,4 +109,26 @@ export const getDispatcherEvents = (emitter: EventEmitter) => ({
emitter.on(getDispatcherChangeByKey(queryKey), callback);
return () => emitter.removeListener(getDispatcherChangeByKey(queryKey), callback);
},
+ /**
+ * When requests start or stop running (sent, finished, canceled or cleared)
+ * @param callback
+ * @returns
+ */
+ onRunningRequestsChange: (callback: (values: RunningRequestsChangeType) => void): VoidFunction => {
+ emitter.on(getDispatcherRunningKey(), callback);
+ return () => emitter.removeListener(getDispatcherRunningKey(), callback);
+ },
+ /**
+ * When requests start or stop running (sent, finished, canceled or cleared) for given queryKey
+ * @param queryKey
+ * @param callback
+ * @returns
+ */
+ onRunningRequestsChangeByKey: (
+ queryKey: string,
+ callback: (values: RunningRequestsChangeType) => void,
+ ): VoidFunction => {
+ emitter.on(getDispatcherRunningByKey(queryKey), callback);
+ return () => emitter.removeListener(getDispatcherRunningByKey(queryKey), callback);
+ },
});
diff --git a/packages/core/src/dispatcher/dispatcher.ts b/packages/core/src/dispatcher/dispatcher.ts
index 0dab0dc9..251ae43e 100644
--- a/packages/core/src/dispatcher/dispatcher.ts
+++ b/packages/core/src/dispatcher/dispatcher.ts
@@ -259,6 +259,7 @@ export class Dispatcher {
const keys = this.getQueuesKeys();
keys.forEach((queryKey) => this.cancelRunningRequests(queryKey));
+ [...this.runningRequests.keys()].forEach((queryKey) => this.setRunningRequests(queryKey, []));
this.runningRequests.clear();
this.storage.clear();
this.client.triggerPlugins("onDispatcherCleared", { dispatcher: this });
@@ -327,14 +328,26 @@ export class Dispatcher {
return runningRequests.find((req) => req.requestId === requestId);
};
+ /**
+ * Single place for changing the running requests - notifies about the change
+ */
+ private setRunningRequests = (queryKey: string, runningRequests: RunningRequestValueType[]) => {
+ const previousRequests = this.getRunningRequests(queryKey);
+ this.runningRequests.set(queryKey, runningRequests);
+ if (
+ previousRequests.length !== runningRequests.length ||
+ previousRequests.some((req, i) => req !== runningRequests[i])
+ ) {
+ this.events.emitRunningRequestsChanged({ queryKey, runningRequests });
+ }
+ };
+
/**
* Add request to the running requests list
*/
addRunningRequest = (queryKey: string, requestId: string, request: RequestInstance): RunningRequestValueType => {
const newRunningRequest = { requestId, request, timestamp: Date.now() };
- const runningRequests = this.getRunningRequests(queryKey);
- runningRequests.push(newRunningRequest);
- this.runningRequests.set(queryKey, runningRequests);
+ this.setRunningRequests(queryKey, [...this.getRunningRequests(queryKey), newRunningRequest]);
return newRunningRequest;
};
@@ -376,14 +389,14 @@ export class Dispatcher {
return true;
});
- this.runningRequests.set(queryKey, requests);
+ this.setRunningRequests(queryKey, requests);
};
/**
* Delete all started requests, but do NOT clear it from queue and do NOT cancel them
*/
deleteRunningRequests = (queryKey: string) => {
- this.runningRequests.set(queryKey, []);
+ this.setRunningRequests(queryKey, []);
};
/**
@@ -391,7 +404,7 @@ export class Dispatcher {
*/
deleteRunningRequest = (queryKey: string, requestId: string) => {
const runningRequests = this.getRunningRequests(queryKey);
- this.runningRequests.set(
+ this.setRunningRequests(
queryKey,
runningRequests.filter((req) => req.requestId !== requestId),
);
diff --git a/packages/core/src/dispatcher/dispatcher.types.ts b/packages/core/src/dispatcher/dispatcher.types.ts
index 51e8644e..f5db2078 100644
--- a/packages/core/src/dispatcher/dispatcher.types.ts
+++ b/packages/core/src/dispatcher/dispatcher.types.ts
@@ -54,3 +54,8 @@ export type RunningRequestValueType = {
request: RequestInstance;
timestamp: number;
};
+
+export type RunningRequestsChangeType = {
+ queryKey: string;
+ runningRequests: RunningRequestValueType[];
+};
diff --git a/packages/core/src/dispatcher/dispatcher.utils.ts b/packages/core/src/dispatcher/dispatcher.utils.ts
index ea55f143..320a48ae 100644
--- a/packages/core/src/dispatcher/dispatcher.utils.ts
+++ b/packages/core/src/dispatcher/dispatcher.utils.ts
@@ -22,6 +22,12 @@ export const getDispatcherChangeKey = (): string => {
export const getDispatcherChangeByKey = (key: string): string => {
return `${key}-change-event`;
};
+export const getDispatcherRunningKey = (): string => {
+ return `running-event`;
+};
+export const getDispatcherRunningByKey = (key: string): string => {
+ return `${key}-running-event`;
+};
// Requesting
diff --git a/packages/core/src/request/request.ts b/packages/core/src/request/request.ts
index d8ea4f37..2305b076 100644
--- a/packages/core/src/request/request.ts
+++ b/packages/core/src/request/request.ts
@@ -98,6 +98,7 @@ export class Request<
used: boolean;
deduplicate: boolean;
deduplicateTime: number | null;
+ structuralSharing: boolean;
scope: string | null;
/**
@@ -190,6 +191,7 @@ export class Request<
queryKey,
deduplicate = false,
deduplicateTime = null,
+ structuralSharing = true,
} = configuration;
this.endpoint = initialRequestConfiguration?.endpoint ?? endpoint;
this.headers = initialRequestConfiguration?.headers ?? headers;
@@ -213,6 +215,7 @@ export class Request<
this.used = initialRequestConfiguration?.used ?? false;
this.deduplicate = initialRequestConfiguration?.deduplicate ?? deduplicate;
this.deduplicateTime = initialRequestConfiguration?.deduplicateTime ?? deduplicateTime;
+ this.structuralSharing = initialRequestConfiguration?.structuralSharing ?? structuralSharing;
this.scope = initialRequestConfiguration?.scope ?? null;
this.updatedAbortKey = initialRequestConfiguration?.updatedAbortKey ?? false;
this.updatedCacheKey = initialRequestConfiguration?.updatedCacheKey ?? false;
@@ -365,6 +368,11 @@ export class Request<
return this.clone({ deduplicateTime });
};
+ /** Enable or disable structural sharing - keeping references of unchanged parts of the response data in cache. */
+ public setStructuralSharing = (structuralSharing: boolean) => {
+ return this.clone({ structuralSharing });
+ };
+
/** Mark the request as used (prevents automatic refetching in hooks). */
public setUsed = (used: boolean) => {
return this.clone({ used });
@@ -506,6 +514,7 @@ export class Request<
updatedQueryKey: this.updatedQueryKey,
deduplicate: this.deduplicate,
deduplicateTime: this.deduplicateTime,
+ structuralSharing: this.structuralSharing,
scope: this.scope,
isMockerEnabled: this.isMockerEnabled,
hasMock: !!this.unstable_mock,
diff --git a/packages/core/src/request/request.types.ts b/packages/core/src/request/request.types.ts
index 1752cd11..55ac78ba 100644
--- a/packages/core/src/request/request.types.ts
+++ b/packages/core/src/request/request.types.ts
@@ -198,6 +198,7 @@ export type RequestJSON = {
updatedQueryKey: boolean;
deduplicate: boolean;
deduplicateTime: number | null;
+ structuralSharing: boolean;
scope: string | null;
isMockerEnabled: boolean;
hasMock: boolean;
@@ -290,6 +291,11 @@ export type RequestOptionsType = (payload: Payload) => NewDataType;
diff --git a/packages/core/src/sdk/sdk.ts b/packages/core/src/sdk/sdk.ts
index 8e5acf5a..3a0ae0f5 100644
--- a/packages/core/src/sdk/sdk.ts
+++ b/packages/core/src/sdk/sdk.ts
@@ -64,6 +64,7 @@ export type SdkRequestDefaults = {
offline?: boolean;
deduplicate?: boolean;
deduplicateTime?: number | null;
+ structuralSharing?: boolean;
};
/**
@@ -175,6 +176,9 @@ const applyObjectDefaults = (request: RequestInstance, config: SdkRequestDefault
if (config.deduplicateTime !== undefined && config.deduplicateTime !== null) {
result = result.setDeduplicateTime(config.deduplicateTime);
}
+ if (config.structuralSharing !== undefined) {
+ result = result.setStructuralSharing(config.structuralSharing);
+ }
return result;
};
diff --git a/packages/react/__tests__/features/helpers/use-request-events/use-request-events.base.spec.ts b/packages/react/__tests__/features/helpers/use-request-events/use-request-events.base.spec.ts
index 3f01a60d..9663130b 100644
--- a/packages/react/__tests__/features/helpers/use-request-events/use-request-events.base.spec.ts
+++ b/packages/react/__tests__/features/helpers/use-request-events/use-request-events.base.spec.ts
@@ -45,12 +45,12 @@ describe("useRequestEvents [ Base ]", () => {
});
it("should not change loading state during cache processing", async () => {
+ const spy = vi.fn();
const { result, rerender } = renderUseRequestEvents(request, {
+ actions: { setLoading: spy },
getIsDataProcessing: () => false,
});
- const spy = vi.spyOn(request.client.fetchDispatcher, "hasRunningRequests");
-
act(() => {
result.current[1].addCacheDataListener(request);
request.client.requestManager.events.emitLoading({
diff --git a/packages/react/__tests__/features/strict-mode/strict-mode.requests.spec.ts b/packages/react/__tests__/features/strict-mode/strict-mode.requests.spec.ts
index d5575c96..49ea1597 100644
--- a/packages/react/__tests__/features/strict-mode/strict-mode.requests.spec.ts
+++ b/packages/react/__tests__/features/strict-mode/strict-mode.requests.spec.ts
@@ -1,6 +1,9 @@
import { createHttpMockingServer } from "@hyper-fetch/testing";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useAppManager } from "hooks/use-app-manager";
+import { useCache } from "hooks/use-cache";
+import { useFetch } from "hooks/use-fetch";
+import { useIsFetching } from "hooks/use-is-fetching";
import { useSubmit } from "hooks/use-submit";
import { StrictMode } from "react";
@@ -107,4 +110,46 @@ describe("Requests hooks [ StrictMode ]", () => {
});
});
});
+
+ describe("given selectors are used in StrictMode", () => {
+ it("should select the fetched data and follow cache updates", async () => {
+ const getRequest = createRequest<{ a: number; b: number }>();
+ mockRequest(getRequest, { data: { a: 1, b: 1 } });
+ const { result } = renderHook(
+ () => ({
+ fetched: useFetch(getRequest, { select: (value) => value.a }),
+ cached: useCache(getRequest, { select: (value) => value.b }),
+ }),
+ { wrapper: StrictMode },
+ );
+
+ await waitFor(() => {
+ expect(result.current.fetched.data).toBe(1);
+ expect(result.current.cached.data).toBe(1);
+ });
+
+ act(() => {
+ client.cache.update(getRequest, (prev) => ({ ...prev, data: { a: 2, b: 3 } }));
+ });
+
+ expect(result.current.fetched.data).toBe(2);
+ expect(result.current.cached.data).toBe(3);
+ });
+ it("should count the running requests", async () => {
+ const getRequest = createRequest();
+ mockRequest(getRequest, { delay: 20 });
+ const { result } = renderHook(() => useIsFetching(client), { wrapper: StrictMode });
+
+ act(() => {
+ client.fetchDispatcher.add(getRequest);
+ });
+
+ await waitFor(() => {
+ expect(result.current).toBe(1);
+ });
+ await waitFor(() => {
+ expect(result.current).toBe(0);
+ });
+ });
+ });
});
diff --git a/packages/react/__tests__/features/strict-mode/strict-mode.sockets.spec.ts b/packages/react/__tests__/features/strict-mode/strict-mode.sockets.spec.ts
index 01a9b504..f3cb91f5 100644
--- a/packages/react/__tests__/features/strict-mode/strict-mode.sockets.spec.ts
+++ b/packages/react/__tests__/features/strict-mode/strict-mode.sockets.spec.ts
@@ -242,6 +242,27 @@ describe("Sockets hooks [ StrictMode ]", () => {
expect(spy).not.toHaveBeenCalled();
});
+ it("should select the message data", async () => {
+ const listener = createListener();
+ const view = renderHook(() => useListener(listener, { select: (message) => message.name }), {
+ wrapper: StrictMode,
+ });
+ act(() => {
+ view.result.current.onEvent(spy);
+ });
+ await waitFor(() => {
+ expect(view.result.current.connected).toBeTrue();
+ });
+ // Read data like a rendering component would - dependency tracking subscribes to the accessed values
+ expect(view.result.current.data).toBeNull();
+
+ emitListenerEvent(listener, { name: "Maciej", age: 99 });
+
+ await waitFor(() => {
+ expect(view.result.current.data).toBe("Maciej");
+ });
+ expect(spy).toHaveBeenCalledTimes(1);
+ });
});
describe("given useEmitter is rendered in StrictMode", () => {
diff --git a/packages/react/__tests__/features/use-cache/use-cache.select.spec.tsx b/packages/react/__tests__/features/use-cache/use-cache.select.spec.tsx
new file mode 100644
index 00000000..935292dc
--- /dev/null
+++ b/packages/react/__tests__/features/use-cache/use-cache.select.spec.tsx
@@ -0,0 +1,117 @@
+import { act, render, screen } from "@testing-library/react";
+import { useCache } from "hooks/use-cache";
+
+import { client, createRequest } from "../../utils";
+
+type Todos = { items: { id: number; done: boolean }[]; total: number };
+
+describe("useCache [ Select ]", () => {
+ let request = createRequest();
+
+ const setCacheData = (data: Todos) => {
+ act(() => {
+ client.cache.set(request, {
+ data,
+ error: null,
+ status: 200,
+ success: true,
+ extra: { headers: {} },
+ retries: 0,
+ isCanceled: false,
+ isOffline: false,
+ willRetry: false,
+ addedTimestamp: Date.now(),
+ triggerTimestamp: Date.now(),
+ requestTimestamp: Date.now(),
+ responseTimestamp: Date.now(),
+ });
+ });
+ };
+
+ beforeEach(() => {
+ client.clear();
+ request = createRequest();
+ });
+
+ it("should return the selected cache data", () => {
+ setCacheData({ items: [{ id: 1, done: false }], total: 1 });
+
+ function Page() {
+ const { data } = useCache(request, { select: (value) => value.total });
+ return {JSON.stringify(data)}
;
+ }
+
+ render( );
+
+ expect(screen.getByTestId("result").textContent).toBe("1");
+ });
+
+ it("should rerender only when the selected value changes", () => {
+ setCacheData({ items: [{ id: 1, done: false }], total: 1 });
+ let renders = 0;
+
+ function Page() {
+ const { data } = useCache(request, { select: (value) => value.items.filter((item) => item.done) });
+ renders += 1;
+ return {JSON.stringify(data)}
;
+ }
+
+ render( );
+ expect(renders).toBe(1);
+
+ // New undone item - the filtered result is still an empty list
+ setCacheData({
+ items: [
+ { id: 1, done: false },
+ { id: 2, done: false },
+ ],
+ total: 2,
+ });
+ expect(renders).toBe(1);
+
+ setCacheData({
+ items: [
+ { id: 1, done: true },
+ { id: 2, done: false },
+ ],
+ total: 2,
+ });
+ expect(renders).toBe(2);
+ expect(screen.getByTestId("result").textContent).toBe(JSON.stringify([{ id: 1, done: true }]));
+ });
+
+ it("should return null without cache data", () => {
+ const selectSpy = vi.fn((value: Todos) => value.total);
+
+ function Page() {
+ const { data } = useCache(request, { select: selectSpy });
+ return {JSON.stringify(data)}
;
+ }
+
+ render( );
+
+ expect(screen.getByTestId("result").textContent).toBe("null");
+ expect(selectSpy).not.toHaveBeenCalled();
+ });
+
+ it("should reset the selected data on clear", () => {
+ setCacheData({ items: [], total: 3 });
+ let clearState: (() => void) | null = null;
+
+ function Page() {
+ const view = useCache(request, { select: (value) => value.total });
+ ({ clearState } = view);
+ return {JSON.stringify(view.data)}
;
+ }
+
+ const view = render( );
+ expect(screen.getByTestId("result").textContent).toBe("3");
+
+ act(() => {
+ clearState?.();
+ });
+ view.rerender( );
+
+ expect(screen.getByTestId("result").textContent).toBe("null");
+ });
+});
diff --git a/packages/react/__tests__/features/use-cache/use-cache.typescript.spec.ts b/packages/react/__tests__/features/use-cache/use-cache.typescript.spec.ts
index 01b4fc57..0cabc8f8 100644
--- a/packages/react/__tests__/features/use-cache/use-cache.typescript.spec.ts
+++ b/packages/react/__tests__/features/use-cache/use-cache.typescript.spec.ts
@@ -1,6 +1,7 @@
import { Client } from "@hyper-fetch/core";
import { expectType, expectNotType, expectFunctionParametersType } from "@hyper-fetch/testing";
import type { UseCacheReturnType } from "hooks/use-cache";
+import { useCache } from "hooks/use-cache";
const client = new Client({
url: "http://localhost:3000",
@@ -107,4 +108,13 @@ describe("useCache [Types]", () => {
expectFunctionParametersType(invalidate).assert([/users/]);
});
});
+
+ describe("select", () => {
+ it("should infer the selected data type", () => {
+ const useSelected = () => useCache(getUsers, { select: (users) => users.length });
+ const data = null as unknown as ReturnType["data"];
+ expectType().assert(data);
+ expectNotType<{ id: string; name: string }[] | null>().assert(data);
+ });
+ });
});
diff --git a/packages/react/__tests__/features/use-fetch/use-fetch.refresh.spec.ts b/packages/react/__tests__/features/use-fetch/use-fetch.refresh.spec.ts
index e1fd1bde..f29b0dd8 100644
--- a/packages/react/__tests__/features/use-fetch/use-fetch.refresh.spec.ts
+++ b/packages/react/__tests__/features/use-fetch/use-fetch.refresh.spec.ts
@@ -1,5 +1,5 @@
import { createHttpMockingServer } from "@hyper-fetch/testing";
-import { act } from "@testing-library/react";
+import { act, waitFor } from "@testing-library/react";
import { client, createRequest, renderUseFetch, waitForRender } from "../../utils";
@@ -44,11 +44,13 @@ describe("useFetch [ Refreshing ]", () => {
result.current.onRequestStart(spy);
});
- await waitForRender();
- expect(spy).toHaveBeenCalledTimes(1);
- await waitForRender(hookOptions.refreshTime * 1.5);
-
- expect(spy).toHaveBeenCalledTimes(2);
+ // Wait for the events instead of fixed sleeps - the timings stretch on a busy machine
+ await waitFor(() => {
+ expect(spy).toHaveBeenCalledTimes(1);
+ });
+ await waitFor(() => {
+ expect(spy).toHaveBeenCalledTimes(2);
+ });
});
it("should refresh blurred tab", async () => {
const spy = vi.fn();
@@ -60,9 +62,9 @@ describe("useFetch [ Refreshing ]", () => {
client.appManager.setFocused(false);
});
- await waitForRender();
- await waitForRender(hookOptions.refreshTime * 1.5);
- expect(spy).toHaveBeenCalledTimes(2);
+ await waitFor(() => {
+ expect(spy).toHaveBeenCalledTimes(2);
+ });
});
it("should not refresh blurred tab", async () => {
const spy = vi.fn();
@@ -93,11 +95,12 @@ describe("useFetch [ Refreshing ]", () => {
result.current.onRequestStart(spy);
});
- await waitForRender();
- expect(spy).toHaveBeenCalledTimes(1);
- await waitForRender(hookOptions.refreshTime * 1.5);
-
- expect(spy).toHaveBeenCalledTimes(2);
+ await waitFor(() => {
+ expect(spy).toHaveBeenCalledTimes(1);
+ });
+ await waitFor(() => {
+ expect(spy).toHaveBeenCalledTimes(2);
+ });
act(() => {
rerender({ refresh: false });
diff --git a/packages/react/__tests__/features/use-fetch/use-fetch.select.spec.tsx b/packages/react/__tests__/features/use-fetch/use-fetch.select.spec.tsx
new file mode 100644
index 00000000..94cac79a
--- /dev/null
+++ b/packages/react/__tests__/features/use-fetch/use-fetch.select.spec.tsx
@@ -0,0 +1,420 @@
+import type { ExtractResponseType, RequestInstance } from "@hyper-fetch/core";
+import { createHttpMockingServer } from "@hyper-fetch/testing";
+import { act, render, screen, waitFor } from "@testing-library/react";
+import { useFetch } from "hooks/use-fetch";
+import type { ReactNode } from "react";
+import { Component, Suspense, useEffect } from "react";
+
+import { client, createRequest } from "../../utils";
+
+const { resetMocks, startServer, stopServer, mockRequest } = createHttpMockingServer();
+
+type Symbols = Record;
+
+class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
+ constructor(props: { children: ReactNode }) {
+ super(props);
+ this.state = { error: null };
+ }
+
+ static getDerivedStateFromError(error: Error) {
+ return { error };
+ }
+
+ render() {
+ const { error } = this.state;
+ const { children } = this.props;
+ if (error) {
+ return {error.message}
;
+ }
+ return children;
+ }
+}
+
+describe("useFetch [ Select ]", () => {
+ const symbols: Symbols = {
+ btc: { price: 1, name: "Bitcoin" },
+ eth: { price: 2, name: "Ethereum" },
+ };
+
+ let request = createRequest();
+
+ const setCacheData = (req: RequestInstance, data: ExtractResponseType) => {
+ act(() => {
+ client.cache.set(req, {
+ data,
+ error: null,
+ status: 200,
+ success: true,
+ extra: {},
+ retries: 0,
+ isCanceled: false,
+ isOffline: false,
+ willRetry: false,
+ addedTimestamp: Date.now(),
+ triggerTimestamp: Date.now(),
+ requestTimestamp: Date.now(),
+ responseTimestamp: Date.now(),
+ });
+ });
+ };
+
+ const waitForData = async (testId = "result") => {
+ await waitFor(() => {
+ expect(screen.getByTestId(testId).textContent).not.toBe("null");
+ });
+ };
+
+ beforeAll(() => {
+ startServer();
+ });
+
+ afterEach(() => {
+ resetMocks();
+ });
+
+ afterAll(() => {
+ stopServer();
+ });
+
+ beforeEach(() => {
+ vi.resetModules();
+ client.clear();
+ request = createRequest();
+ });
+
+ describe("when selecting part of the response", () => {
+ it("should return the selected data", async () => {
+ mockRequest(request, { data: symbols });
+
+ function Page() {
+ const { data } = useFetch(request, { select: (value) => value.btc });
+ return {JSON.stringify(data)}
;
+ }
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("result").textContent).toBe(JSON.stringify(symbols.btc));
+ });
+ });
+
+ it("should share one request between hooks selecting different parts", async () => {
+ mockRequest(request, { data: symbols });
+ const responseSpy = vi.fn();
+ const unmount = client.requestManager.events.onResponse(responseSpy);
+
+ function Price({ id }: { id: string }) {
+ const { data } = useFetch(request, { select: (value) => value[id].price });
+ return {JSON.stringify(data)}
;
+ }
+
+ render(
+ <>
+
+
+ >,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("btc").textContent).toBe("1");
+ expect(screen.getByTestId("eth").textContent).toBe("2");
+ });
+ expect(responseSpy).toHaveBeenCalledTimes(1);
+ unmount();
+ });
+
+ it("should not rerender when the unselected part of the data changes", async () => {
+ mockRequest(request, { data: symbols });
+ let renders = 0;
+
+ function Page() {
+ const { data } = useFetch(request, { select: (value) => value.btc });
+ renders += 1;
+ return {JSON.stringify(data)}
;
+ }
+
+ render( );
+ await waitForData();
+ const rendersAfterLoad = renders;
+
+ setCacheData(request, { ...symbols, eth: { price: 999, name: "Ethereum" } });
+ setCacheData(request, { ...symbols, eth: { price: 1000, name: "Ethereum" } });
+
+ expect(renders).toBe(rendersAfterLoad);
+
+ setCacheData(request, { ...symbols, btc: { price: 5, name: "Bitcoin" } });
+
+ expect(renders).toBe(rendersAfterLoad + 1);
+ expect(screen.getByTestId("result").textContent).toBe(JSON.stringify({ price: 5, name: "Bitcoin" }));
+ });
+
+ it("should keep the reference of a recreated selected object when it is deeply equal", async () => {
+ mockRequest(request, { data: symbols });
+ const references = new Set();
+ const effectSpy = vi.fn();
+
+ function Page() {
+ // Inline selector creating a new object on every call
+ const { data, loading } = useFetch(request, { select: (value) => ({ price: value.btc.price }) });
+ if (data) {
+ references.add(data);
+ }
+ useEffect(() => {
+ effectSpy();
+ }, [data]);
+ return {loading ? "loading" : JSON.stringify(data)}
;
+ }
+
+ render( );
+ await waitFor(() => {
+ expect(screen.getByTestId("result").textContent).toBe(JSON.stringify({ price: 1 }));
+ });
+ const effectsAfterLoad = effectSpy.mock.calls.length;
+
+ setCacheData(request, { ...symbols, eth: { price: 3, name: "Ethereum" } });
+ setCacheData(request, { btc: { price: 1, name: "Renamed" }, eth: { price: 3, name: "Ethereum" } });
+
+ expect(references.size).toBe(1);
+ expect(effectSpy).toHaveBeenCalledTimes(effectsAfterLoad);
+ });
+
+ it("should re-run the selector when it changes between renders", async () => {
+ mockRequest(request, { data: symbols });
+
+ function Page({ id }: { id: string }) {
+ const { data } = useFetch(request, { select: (value) => value[id].name });
+ return {JSON.stringify(data)}
;
+ }
+
+ const view = render( );
+ await waitFor(() => {
+ expect(screen.getByTestId("result").textContent).toBe(JSON.stringify("Bitcoin"));
+ });
+
+ view.rerender( );
+
+ // Same render - without waiting for any new data
+ expect(screen.getByTestId("result").textContent).toBe(JSON.stringify("Ethereum"));
+ });
+
+ it("should apply the selector to the cached data on mount", async () => {
+ setCacheData(request, symbols);
+
+ function Page() {
+ const { data } = useFetch(request, { select: (value) => value.eth.price, revalidate: false });
+ return {JSON.stringify(data)}
;
+ }
+
+ render( );
+
+ expect(screen.getByTestId("result").textContent).toBe("2");
+ });
+
+ it("should apply the selector to the initial response", async () => {
+ mockRequest(request, { data: symbols, delay: 50 });
+
+ function Page() {
+ const { data } = useFetch(request, {
+ select: (value) => value.btc.name,
+ initialResponse: { data: { btc: { price: 0, name: "Initial" } } },
+ });
+ return {JSON.stringify(data)}
;
+ }
+
+ render( );
+
+ expect(screen.getByTestId("result").textContent).toBe(JSON.stringify("Initial"));
+ await waitFor(() => {
+ expect(screen.getByTestId("result").textContent).toBe(JSON.stringify("Bitcoin"));
+ });
+ });
+
+ it("should keep the data set locally with setData while selector is recreated on every render", async () => {
+ mockRequest(request, { data: symbols });
+ let setData: ((value: number) => void) | null = null;
+
+ function Page() {
+ const view = useFetch(request, { select: (value) => value.btc.price });
+ setData = view.setData;
+ return {JSON.stringify(view.data)}
;
+ }
+
+ render( );
+ await waitFor(() => {
+ expect(screen.getByTestId("result").textContent).toBe("1");
+ });
+
+ act(() => {
+ setData?.(42);
+ });
+
+ expect(screen.getByTestId("result").textContent).toBe("42");
+
+ // Next response replaces the local data
+ setCacheData(request, { ...symbols, btc: { price: 7, name: "Bitcoin" } });
+ expect(screen.getByTestId("result").textContent).toBe("7");
+ });
+
+ it("should not call the selector for empty data", async () => {
+ mockRequest(request, { status: 400, delay: 10 });
+ const selectSpy = vi.fn((value: Symbols) => value.btc);
+
+ function Page() {
+ const { data, error } = useFetch(request, { select: selectSpy });
+ return {error ? "error" : JSON.stringify(data)}
;
+ }
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("result").textContent).toBe("error");
+ });
+ expect(selectSpy).not.toHaveBeenCalled();
+ });
+
+ it("should pass the selector error to the error boundary", async () => {
+ mockRequest(request, { data: symbols });
+ vi.spyOn(console, "error").mockImplementation(() => null);
+
+ function Page() {
+ const { data } = useFetch(request, {
+ select: (value) => {
+ if (value.btc.price > 1) {
+ throw new Error("Selector failed");
+ }
+ return value.btc;
+ },
+ });
+ return {JSON.stringify(data)}
;
+ }
+
+ render(
+
+
+ ,
+ );
+ await waitForData();
+
+ setCacheData(request, { ...symbols, btc: { price: 2, name: "Bitcoin" } });
+
+ await waitFor(() => {
+ expect(screen.getByTestId("error-boundary").textContent).toBe("Selector failed");
+ });
+ });
+
+ it("should apply the selector in suspense mode", async () => {
+ mockRequest(request, { data: symbols });
+
+ function Page() {
+ const { data } = useFetch(request, { suspense: true, select: (value) => value.eth.name });
+ return {JSON.stringify(data)}
;
+ }
+
+ render(
+ }>
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("result").textContent).toBe(JSON.stringify("Ethereum"));
+ });
+ });
+ });
+
+ describe("when using structural sharing", () => {
+ it("should keep the data reference when the same data is received again", async () => {
+ mockRequest(request, { data: symbols });
+ const effectSpy = vi.fn();
+ let refetch: (() => void) | null = null;
+
+ function Page() {
+ const view = useFetch(request);
+ refetch = view.refetch;
+ useEffect(() => {
+ effectSpy(view.data);
+ }, [view.data]);
+ return {view.loading ? "loading" : JSON.stringify(view.data)}
;
+ }
+
+ render( );
+ await waitFor(() => {
+ expect(screen.getByTestId("result").textContent).toBe(JSON.stringify(symbols));
+ });
+ const firstData = effectSpy.mock.lastCall?.[0];
+
+ act(() => {
+ refetch?.();
+ });
+ await waitFor(() => {
+ expect(screen.getByTestId("result").textContent).toBe(JSON.stringify(symbols));
+ });
+
+ expect(effectSpy.mock.lastCall?.[0]).toBe(firstData);
+ // null -> data, never a second time for the equal response
+ expect(effectSpy.mock.calls.filter(([value]) => value !== null)).toHaveLength(1);
+ });
+
+ it("should reuse unchanged parts of the data", async () => {
+ setCacheData(request, symbols);
+ let current: Symbols | null = null;
+
+ function Page() {
+ const { data } = useFetch(request, { revalidate: false });
+ current = data;
+ return {JSON.stringify(data)}
;
+ }
+
+ render( );
+ const first = current as unknown as Symbols;
+
+ setCacheData(request, { btc: { price: 1, name: "Bitcoin" }, eth: { price: 5, name: "Ethereum" } });
+ const second = current as unknown as Symbols;
+
+ expect(second).not.toBe(first);
+ expect(second.btc).toBe(first.btc);
+ expect(second.eth).toStrictEqual({ price: 5, name: "Ethereum" });
+ });
+
+ it("should allow to disable structural sharing of the selected data", async () => {
+ setCacheData(request, symbols);
+ let current: unknown = null;
+
+ function Page() {
+ const { data } = useFetch(request, {
+ revalidate: false,
+ structuralSharing: false,
+ deepCompare: false,
+ select: (value) => ({ price: value.btc.price }),
+ });
+ current = data;
+ return {JSON.stringify(data)}
;
+ }
+
+ render( );
+ const first = current;
+ setCacheData(request, { ...symbols, eth: { price: 3, name: "Ethereum" } });
+
+ expect(current).not.toBe(first);
+ expect(current).toStrictEqual(first);
+ });
+
+ it("should give new references when sharing is disabled everywhere", async () => {
+ const unsharedRequest = request.setStructuralSharing(false);
+ setCacheData(unsharedRequest, symbols);
+ const references = new Set();
+
+ function Page() {
+ const { data } = useFetch(unsharedRequest, { revalidate: false, deepCompare: false });
+ references.add(data);
+ return {JSON.stringify(data)}
;
+ }
+
+ render( );
+ setCacheData(unsharedRequest, { ...symbols });
+
+ expect(references.size).toBe(2);
+ });
+ });
+});
diff --git a/packages/react/__tests__/features/use-fetch/use-fetch.throw-on-error.spec.tsx b/packages/react/__tests__/features/use-fetch/use-fetch.throw-on-error.spec.tsx
new file mode 100644
index 00000000..65923844
--- /dev/null
+++ b/packages/react/__tests__/features/use-fetch/use-fetch.throw-on-error.spec.tsx
@@ -0,0 +1,213 @@
+import { createHttpMockingServer } from "@hyper-fetch/testing";
+import { act, render, screen, waitFor } from "@testing-library/react";
+import { useFetch } from "hooks/use-fetch";
+import type { ReactNode } from "react";
+import { Component, Suspense } from "react";
+
+import { client, createRequest } from "../../utils";
+
+const { resetMocks, startServer, stopServer, mockRequest } = createHttpMockingServer();
+
+class ErrorBoundary extends Component<{ children: ReactNode }, { error: unknown }> {
+ constructor(props: { children: ReactNode }) {
+ super(props);
+ this.state = { error: null };
+ }
+
+ static getDerivedStateFromError(error: unknown) {
+ return { error };
+ }
+
+ handleReset = () => {
+ // eslint-disable-next-line react/no-set-state
+ this.setState({ error: null });
+ };
+
+ render() {
+ const { error } = this.state;
+ const { children } = this.props;
+ if (error) {
+ return (
+
+ {JSON.stringify(error)}
+
+ );
+ }
+ return children;
+ }
+}
+
+describe("useFetch [ Throw On Error ]", () => {
+ let request = createRequest();
+
+ beforeAll(() => {
+ startServer();
+ });
+
+ afterEach(() => {
+ resetMocks();
+ });
+
+ afterAll(() => {
+ stopServer();
+ });
+
+ beforeEach(() => {
+ vi.resetModules();
+ vi.restoreAllMocks();
+ // React logs the errors caught by boundaries
+ vi.spyOn(console, "error").mockImplementation(() => null);
+ client.clear();
+ request = createRequest();
+ });
+
+ function Page({ throwOnError }: { throwOnError: boolean | ((error: any) => boolean) }) {
+ // Component does not read the error - the hook still has to throw it
+ const { data, loading } = useFetch(request, { throwOnError });
+ return {loading ? "loading" : JSON.stringify(data)}
;
+ }
+
+ it("should throw the error to the error boundary", async () => {
+ const error = mockRequest(request, { status: 400 });
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("error-boundary").textContent).toBe(JSON.stringify(error));
+ });
+ });
+
+ it("should not throw when the request succeeds", async () => {
+ const data = mockRequest(request, { data: { value: 1 } });
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("result").textContent).toBe(JSON.stringify(data));
+ });
+ expect(screen.queryByTestId("error-boundary")).toBeNull();
+ });
+
+ it("should keep the error in the state when disabled", async () => {
+ mockRequest(request, { status: 400 });
+
+ function ErrorPage() {
+ const { error, loading } = useFetch(request);
+ const status = error ? "error" : "none";
+ return {loading ? "loading" : status}
;
+ }
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("result").textContent).toBe("error");
+ });
+ expect(screen.queryByTestId("error-boundary")).toBeNull();
+ });
+
+ it("should decide with the predicate", async () => {
+ mockRequest(request, { status: 400 });
+ const predicate = vi.fn(() => false);
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(predicate).toHaveBeenCalled();
+ });
+ expect(screen.queryByTestId("error-boundary")).toBeNull();
+ expect(predicate.mock.calls[0]).toHaveLength(1);
+ });
+
+ it("should throw when the predicate allows it", async () => {
+ mockRequest(request, { status: 400 });
+
+ render(
+
+ !!error} />
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("error-boundary")).toBeInTheDocument();
+ });
+ });
+
+ it("should throw only after the retries are finished", async () => {
+ request = createRequest().setRetry(2).setRetryTime(10);
+ mockRequest(request, { status: 500 });
+ const responseSpy = vi.fn();
+ const unmount = client.requestManager.events.onResponse(responseSpy);
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("error-boundary")).toBeInTheDocument();
+ });
+ expect(responseSpy).toHaveBeenCalledTimes(3);
+ unmount();
+ });
+
+ it("should fetch again after the error boundary reset", async () => {
+ mockRequest(request, { status: 400 });
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("error-boundary")).toBeInTheDocument();
+ });
+
+ const data = mockRequest(request, { data: { recovered: true } });
+ act(() => {
+ screen.getByTestId("error-boundary").click();
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId("result").textContent).toBe(JSON.stringify(data));
+ });
+ });
+
+ it("should throw in suspense mode", async () => {
+ const error = mockRequest(request, { status: 400 });
+
+ function SuspensePage() {
+ const { data } = useFetch(request, { suspense: true, throwOnError: true });
+ return {JSON.stringify(data)}
;
+ }
+
+ render(
+
+ }>
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("error-boundary").textContent).toBe(JSON.stringify(error));
+ });
+ });
+});
diff --git a/packages/react/__tests__/features/use-fetch/use-fetch.typescript.spec.ts b/packages/react/__tests__/features/use-fetch/use-fetch.typescript.spec.ts
index c7523ca4..2fa2ba46 100644
--- a/packages/react/__tests__/features/use-fetch/use-fetch.typescript.spec.ts
+++ b/packages/react/__tests__/features/use-fetch/use-fetch.typescript.spec.ts
@@ -1,6 +1,7 @@
import { Client } from "@hyper-fetch/core";
import { expectType, expectNotType, expectFunctionParametersType } from "@hyper-fetch/testing";
import type { UseFetchReturnType } from "hooks/use-fetch";
+import { useFetch } from "hooks/use-fetch";
const client = new Client({
url: "http://localhost:3000",
@@ -178,4 +179,44 @@ describe("useFetch [Types]", () => {
expectType<() => void>().assert(bounce.reset);
});
});
+
+ describe("select", () => {
+ it("should infer the selected data type", () => {
+ const useSelected = () => useFetch(getUsers, { select: (users) => users.map((user) => user.name) });
+ const data = null as unknown as ReturnType["data"];
+ const setData = null as unknown as ReturnType["setData"];
+ expectType().assert(data);
+ expectNotType<{ id: string; name: string }[] | null>().assert(data);
+ expectFunctionParametersType(setData).assert([["name"]]);
+ });
+
+ it("should type selector argument as the response", () => {
+ const useSelected = () =>
+ useFetch(getUser, {
+ select: (user) => {
+ expectType<{ id: string; name: string; email: string }>().assert(user);
+ return user.email;
+ },
+ });
+ const data = null as unknown as ReturnType["data"];
+ expectType().assert(data);
+ });
+
+ it("should keep the response type without selector", () => {
+ const useDefault = () => useFetch(getUsers, { structuralSharing: false });
+ const data = null as unknown as ReturnType["data"];
+ expectType<{ id: string; name: string }[] | null>().assert(data);
+ });
+
+ it("should type throwOnError predicate with the request error", () => {
+ const useThrowing = () =>
+ useFetch(getUserWithError, {
+ throwOnError: (error) => {
+ expectType<{ code: number; message: string; details: string[] } | Error>().assert(error);
+ return true;
+ },
+ });
+ expectType<() => unknown>().assert(useThrowing);
+ });
+ });
});
diff --git a/packages/react/__tests__/features/use-is-fetching/use-is-fetching.base.spec.tsx b/packages/react/__tests__/features/use-is-fetching/use-is-fetching.base.spec.tsx
new file mode 100644
index 00000000..08e66967
--- /dev/null
+++ b/packages/react/__tests__/features/use-is-fetching/use-is-fetching.base.spec.tsx
@@ -0,0 +1,117 @@
+import { createHttpMockingServer } from "@hyper-fetch/testing";
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { useIsFetching, useIsSubmitting } from "hooks/use-is-fetching";
+
+import { client, createRequest } from "../../utils";
+
+const { resetMocks, startServer, stopServer, mockRequest } = createHttpMockingServer();
+
+describe("useIsFetching [ Base ]", () => {
+ beforeAll(() => {
+ startServer();
+ });
+
+ afterEach(() => {
+ resetMocks();
+ });
+
+ afterAll(() => {
+ stopServer();
+ });
+
+ beforeEach(() => {
+ vi.resetModules();
+ client.clear();
+ });
+
+ it("should count the running fetch requests", async () => {
+ const usersRequest = createRequest({ endpoint: "/users" });
+ const postsRequest = createRequest({ endpoint: "/posts" });
+ mockRequest(usersRequest, { delay: 30 });
+ mockRequest(postsRequest, { delay: 60 });
+
+ const { result } = renderHook(() => useIsFetching(client));
+ expect(result.current).toBe(0);
+
+ act(() => {
+ client.fetchDispatcher.add(usersRequest);
+ client.fetchDispatcher.add(postsRequest);
+ });
+
+ await waitFor(() => {
+ expect(result.current).toBe(2);
+ });
+ await waitFor(() => {
+ expect(result.current).toBe(1);
+ });
+ await waitFor(() => {
+ expect(result.current).toBe(0);
+ });
+ });
+
+ it("should filter the requests by query key and predicate", async () => {
+ const usersRequest = createRequest({ endpoint: "/users" });
+ const postsRequest = createRequest({ endpoint: "/posts" });
+ mockRequest(usersRequest, { delay: 30 });
+ mockRequest(postsRequest, { delay: 30 });
+
+ const { result } = renderHook(() => ({
+ exact: useIsFetching(client, { queryKey: usersRequest.queryKey }),
+ regex: useIsFetching(client, { queryKey: /posts/ }),
+ predicate: useIsFetching(client, { predicate: (request) => request.endpoint === "/missing" }),
+ }));
+
+ act(() => {
+ client.fetchDispatcher.add(usersRequest);
+ client.fetchDispatcher.add(postsRequest);
+ });
+
+ await waitFor(() => {
+ expect(result.current).toStrictEqual({ exact: 1, regex: 1, predicate: 0 });
+ });
+ await waitFor(() => {
+ expect(result.current).toStrictEqual({ exact: 0, regex: 0, predicate: 0 });
+ });
+ });
+
+ it("should count canceled requests out", async () => {
+ const request = createRequest();
+ mockRequest(request, { delay: 1000 });
+
+ const { result } = renderHook(() => useIsFetching(client));
+
+ act(() => {
+ client.fetchDispatcher.add(request);
+ });
+ await waitFor(() => {
+ expect(result.current).toBe(1);
+ });
+
+ act(() => {
+ client.fetchDispatcher.cancelRunningRequests(request.queryKey);
+ });
+
+ expect(result.current).toBe(0);
+ });
+
+ it("should count the running submit requests separately", async () => {
+ const request = createRequest({ method: "POST" });
+ mockRequest(request, { delay: 30 });
+
+ const { result } = renderHook(() => ({
+ fetching: useIsFetching(client),
+ submitting: useIsSubmitting(client),
+ }));
+
+ act(() => {
+ client.submitDispatcher.add(request);
+ });
+
+ await waitFor(() => {
+ expect(result.current).toStrictEqual({ fetching: 0, submitting: 1 });
+ });
+ await waitFor(() => {
+ expect(result.current).toStrictEqual({ fetching: 0, submitting: 0 });
+ });
+ });
+});
diff --git a/packages/react/__tests__/features/use-listener/use-listener.select.spec.tsx b/packages/react/__tests__/features/use-listener/use-listener.select.spec.tsx
new file mode 100644
index 00000000..0f18af05
--- /dev/null
+++ b/packages/react/__tests__/features/use-listener/use-listener.select.spec.tsx
@@ -0,0 +1,183 @@
+import { createWebsocketMockingServer, waitForConnection } from "@hyper-fetch/testing";
+import { act, render, screen, waitFor } from "@testing-library/react";
+import { useListener } from "hooks/use-listener";
+import type { ReactNode } from "react";
+import { Component, useEffect } from "react";
+
+import { createListener } from "../../utils/listener.utils";
+
+type Prices = Record;
+
+class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
+ constructor(props: { children: ReactNode }) {
+ super(props);
+ this.state = { error: null };
+ }
+
+ static getDerivedStateFromError(error: Error) {
+ return { error };
+ }
+
+ render() {
+ const { error } = this.state;
+ const { children } = this.props;
+ return error ? {error.message}
: children;
+ }
+}
+
+describe("useListener [ Select ]", () => {
+ const { startServer, stopServer, emitListenerEvent } = createWebsocketMockingServer();
+ let listener = createListener();
+
+ beforeEach(async () => {
+ startServer();
+ listener = createListener();
+ await waitForConnection(listener.socket);
+ vi.resetModules();
+ vi.restoreAllMocks();
+ });
+
+ afterEach(() => {
+ stopServer();
+ });
+
+ const emit = async (message: Prices, onEvent: ReturnType, count: number) => {
+ act(() => {
+ emitListenerEvent(listener, message);
+ });
+ // Wait for the message delivery instead of rendering, which may not happen on purpose
+ await waitFor(() => {
+ expect(onEvent).toHaveBeenCalledTimes(count);
+ });
+ };
+
+ it("should select the message data and rerender only when it changes", async () => {
+ const onEvent = vi.fn();
+ let renders = 0;
+
+ function Page() {
+ const view = useListener(listener, { select: (value) => value.btc.price });
+ view.onEvent(onEvent);
+ renders += 1;
+ return {JSON.stringify(view.data)}
;
+ }
+
+ render( );
+ const initialRenders = renders;
+
+ await emit({ btc: { price: 1 }, eth: { price: 1 } }, onEvent, 1);
+ await waitFor(() => {
+ expect(screen.getByTestId("result").textContent).toBe("1");
+ });
+ const rendersAfterFirst = renders;
+ expect(rendersAfterFirst).toBe(initialRenders + 1);
+
+ await emit({ btc: { price: 1 }, eth: { price: 2 } }, onEvent, 2);
+ await emit({ btc: { price: 1 }, eth: { price: 3 } }, onEvent, 3);
+ expect(renders).toBe(rendersAfterFirst);
+
+ await emit({ btc: { price: 4 }, eth: { price: 3 } }, onEvent, 4);
+ await waitFor(() => {
+ expect(screen.getByTestId("result").textContent).toBe("4");
+ });
+ expect(onEvent.mock.lastCall?.[0].data).toStrictEqual({ btc: { price: 4 }, eth: { price: 3 } });
+ });
+
+ it("should keep the reference of deeply equal selected objects", async () => {
+ const onEvent = vi.fn();
+ const effectSpy = vi.fn();
+
+ function Page() {
+ const view = useListener(listener, { select: (value) => ({ btc: value.btc }) });
+ view.onEvent(onEvent);
+ useEffect(() => {
+ effectSpy(view.data);
+ }, [view.data]);
+ return {JSON.stringify(view.data)}
;
+ }
+
+ render( );
+
+ await emit({ btc: { price: 1 }, eth: { price: 1 } }, onEvent, 1);
+ await emit({ btc: { price: 1 }, eth: { price: 2 } }, onEvent, 2);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("result").textContent).toBe(JSON.stringify({ btc: { price: 1 } }));
+ });
+ // initial null and first message only
+ expect(effectSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it("should update data on every message without a selector", async () => {
+ const onEvent = vi.fn();
+ const effectSpy = vi.fn();
+
+ function Page() {
+ const view = useListener(listener);
+ view.onEvent(onEvent);
+ useEffect(() => {
+ effectSpy(view.data);
+ }, [view.data]);
+ return {JSON.stringify(view.data)}
;
+ }
+
+ render( );
+
+ await emit({ btc: { price: 1 } }, onEvent, 1);
+ await emit({ btc: { price: 1 } }, onEvent, 2);
+
+ await waitFor(() => {
+ expect(effectSpy).toHaveBeenCalledTimes(3);
+ });
+ });
+
+ it("should re-run the selector when it changes between renders", async () => {
+ const onEvent = vi.fn();
+
+ function Page({ id }: { id: string }) {
+ const view = useListener(listener, { select: (value) => value[id].price });
+ view.onEvent(onEvent);
+ return {JSON.stringify(view.data)}
;
+ }
+
+ const view = render( );
+ await emit({ btc: { price: 1 }, eth: { price: 2 } }, onEvent, 1);
+ await waitFor(() => {
+ expect(screen.getByTestId("result").textContent).toBe("1");
+ });
+
+ view.rerender( );
+
+ expect(screen.getByTestId("result").textContent).toBe("2");
+ });
+
+ it("should pass the selector error to the error boundary", async () => {
+ vi.spyOn(console, "error").mockImplementation(() => null);
+ const onEvent = vi.fn();
+
+ function Page() {
+ const view = useListener(listener, {
+ select: (value) => {
+ if (!value.btc) {
+ throw new Error("Missing btc");
+ }
+ return value.btc.price;
+ },
+ });
+ view.onEvent(onEvent);
+ return {JSON.stringify(view.data)}
;
+ }
+
+ render(
+
+
+ ,
+ );
+
+ await emit({ eth: { price: 1 } }, onEvent, 1);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("error-boundary").textContent).toBe("Missing btc");
+ });
+ });
+});
diff --git a/packages/react/__tests__/features/use-listener/use-listener.typescript.spec.ts b/packages/react/__tests__/features/use-listener/use-listener.typescript.spec.ts
new file mode 100644
index 00000000..973bd036
--- /dev/null
+++ b/packages/react/__tests__/features/use-listener/use-listener.typescript.spec.ts
@@ -0,0 +1,23 @@
+import { expectType, expectNotType } from "@hyper-fetch/testing";
+import { useListener } from "hooks/use-listener";
+
+import { createListener } from "../../utils/listener.utils";
+
+const listener = createListener<{ prices: Record; updatedAt: number }>();
+
+describe("useListener [ TypeScript ]", () => {
+ describe("select", () => {
+ it("should infer the selected data type", () => {
+ const useSelected = () => useListener(listener, { select: (message) => message.prices.btc });
+ const data = null as unknown as ReturnType["data"];
+ expectType().assert(data);
+ expectNotType<{ prices: Record; updatedAt: number } | null>().assert(data);
+ });
+
+ it("should keep the message type without selector", () => {
+ const useDefault = () => useListener(listener);
+ const data = null as unknown as ReturnType["data"];
+ expectType<{ prices: Record; updatedAt: number } | null>().assert(data);
+ });
+ });
+});
diff --git a/packages/react/__tests__/features/use-submit/use-submit.select.spec.tsx b/packages/react/__tests__/features/use-submit/use-submit.select.spec.tsx
new file mode 100644
index 00000000..283b5070
--- /dev/null
+++ b/packages/react/__tests__/features/use-submit/use-submit.select.spec.tsx
@@ -0,0 +1,55 @@
+import { createHttpMockingServer } from "@hyper-fetch/testing";
+import { act, render, screen, waitFor } from "@testing-library/react";
+import { useSubmit } from "hooks/use-submit";
+
+import { client, createRequest } from "../../utils";
+
+const { resetMocks, startServer, stopServer, mockRequest } = createHttpMockingServer();
+
+describe("useSubmit [ Select ]", () => {
+ let request = createRequest<{ user: { id: number; name: string }; token: string }>({ method: "POST" });
+
+ beforeAll(() => {
+ startServer();
+ });
+
+ afterEach(() => {
+ resetMocks();
+ });
+
+ afterAll(() => {
+ stopServer();
+ });
+
+ beforeEach(() => {
+ vi.resetModules();
+ client.clear();
+ request = createRequest<{ user: { id: number; name: string }; token: string }>({ method: "POST" });
+ });
+
+ it("should select the response data while callbacks and submit get the full response", async () => {
+ const mock = mockRequest(request, { data: { user: { id: 1, name: "Maciej" }, token: "secret" } });
+ const onSuccess = vi.fn();
+ let submit: (() => Promise) | null = null;
+
+ function Page() {
+ const view = useSubmit(request, { select: (value) => value.user.name });
+ ({ submit } = view);
+ view.onSubmitSuccess(({ response }) => onSuccess(response.data));
+ return {JSON.stringify(view.data)}
;
+ }
+
+ render( );
+
+ let submitResponse: any;
+ await act(async () => {
+ submitResponse = await submit?.();
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId("result").textContent).toBe(JSON.stringify("Maciej"));
+ });
+ expect(onSuccess).toHaveBeenCalledWith(mock);
+ expect(submitResponse.data).toStrictEqual(mock);
+ });
+});
diff --git a/packages/react/__tests__/features/use-submit/use-submit.typescript.spec.ts b/packages/react/__tests__/features/use-submit/use-submit.typescript.spec.ts
index f6b45161..04fc385c 100644
--- a/packages/react/__tests__/features/use-submit/use-submit.typescript.spec.ts
+++ b/packages/react/__tests__/features/use-submit/use-submit.typescript.spec.ts
@@ -7,6 +7,7 @@ import {
expectNotFunctionParametersType,
} from "@hyper-fetch/testing";
import type { UseSubmitReturnType } from "hooks/use-submit";
+import { useSubmit } from "hooks/use-submit";
const client = new Client({
url: "http://localhost:3000",
@@ -321,4 +322,17 @@ describe("useSubmit [Types]", () => {
expectType<{ saved: { name?: string | undefined } }>().assert({} as Ctx);
});
});
+
+ describe("select", () => {
+ it("should infer the selected data type", () => {
+ const request = client.createRequest<{ response: { id: string; token: string }; payload: { name: string } }>()({
+ method: "POST",
+ endpoint: "/users",
+ });
+ const useSelected = () => useSubmit(request, { select: (value) => value.token });
+ const data = null as unknown as ReturnType["data"];
+ expectType().assert(data);
+ expectNotType<{ id: string; token: string } | null>().assert(data);
+ });
+ });
});
diff --git a/packages/react/__tests__/utils/use-fetch.utils.ts b/packages/react/__tests__/utils/use-fetch.utils.ts
index 3500f845..ad235004 100644
--- a/packages/react/__tests__/utils/use-fetch.utils.ts
+++ b/packages/react/__tests__/utils/use-fetch.utils.ts
@@ -6,6 +6,9 @@ import { useFetch } from "hooks/use-fetch";
export const renderUseFetch = (request: T, options?: UseFetchOptionsType) => {
return renderHook((rerenderOptions: UseFetchOptionsType & { request?: RequestInstance }) => {
const { request: req, ...rest } = rerenderOptions || {};
- return useFetch(req || request, { dependencyTracking: false, ...options, ...rest } as UseFetchOptionsType);
+ return useFetch(
+ (req || request) as T,
+ { dependencyTracking: false, ...options, ...rest } as UseFetchOptionsType,
+ );
});
};
diff --git a/packages/react/src/helpers/use-request-events/use-request-events.types.ts b/packages/react/src/helpers/use-request-events/use-request-events.types.ts
index 6d787cfa..2319768b 100644
--- a/packages/react/src/helpers/use-request-events/use-request-events.types.ts
+++ b/packages/react/src/helpers/use-request-events/use-request-events.types.ts
@@ -26,7 +26,7 @@ export type UseRequestEventsPropsType = {
request: T;
dispatcher: Dispatcher>;
logger: LoggerMethods;
- actions: UseTrackedStateActions;
+ actions: Pick, "setLoading">;
setCacheData: (cacheData: CacheValueType, ExtractErrorType>) => void;
getIsDataProcessing: (cacheKey: string) => boolean;
};
diff --git a/packages/react/src/helpers/use-socket-state/use-socket-state.hooks.ts b/packages/react/src/helpers/use-socket-state/use-socket-state.hooks.ts
index 7bef22c0..fce43cdc 100644
--- a/packages/react/src/helpers/use-socket-state/use-socket-state.hooks.ts
+++ b/packages/react/src/helpers/use-socket-state/use-socket-state.hooks.ts
@@ -185,5 +185,7 @@ export const useSocketState = (
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [socket]);
- return [state.current, actions, callbacks, { setRenderKey }] as const;
+ const getState = () => state.current;
+
+ return [state.current, actions, callbacks, { setRenderKey, getState, rerender: emitChange }] as const;
};
diff --git a/packages/react/src/helpers/use-tracked-state/use-tracked-state.hooks.ts b/packages/react/src/helpers/use-tracked-state/use-tracked-state.hooks.ts
index 54cd4df6..7ec8877f 100644
--- a/packages/react/src/helpers/use-tracked-state/use-tracked-state.hooks.ts
+++ b/packages/react/src/helpers/use-tracked-state/use-tracked-state.hooks.ts
@@ -9,7 +9,7 @@ import type {
ExtractAdapterExtraType,
ResponseType,
} from "@hyper-fetch/core";
-import { scopeKey } from "@hyper-fetch/core";
+import { replaceEqualDeep, scopeKey } from "@hyper-fetch/core";
import { useCallback, useRef, useSyncExternalStore } from "react";
import { isEqual } from "utils";
@@ -18,6 +18,7 @@ import type {
UseTrackedStateType,
UseTrackedStateProps,
UseTrackedStateReturn,
+ UseTrackedStateResponseDetails,
} from "./use-tracked-state.types";
import {
getInitialState,
@@ -35,7 +36,7 @@ import {
* @param dependencies
* @internal
*/
-export const useTrackedState = ({
+export const useTrackedState = >({
request,
dispatcher,
initialResponse,
@@ -44,15 +45,64 @@ export const useTrackedState = ({
keepPreviousData = "auto",
disabled,
revalidate,
-}: UseTrackedStateProps): UseTrackedStateReturn => {
+ select,
+ structuralSharing = true,
+}: UseTrackedStateProps): UseTrackedStateReturn => {
const { client, cacheKey, queryKey, staleTime, unstable_responseMapper } = request;
const { cache } = client;
- const state = useRef>(getInitialState({ initialResponse, dispatcher, request, disabled }));
- const renderKeys = useRef<(keyof UseTrackedStateType)[]>([]);
+ const state = useRef>(null as unknown as UseTrackedStateType);
+ const renderKeys = useRef<(keyof UseTrackedStateType)[]>([]);
const isProcessingData = useRef("");
const previousCacheKey = useRef(cacheKey);
+ // ******************
+ // Data selection
+ // ******************
+
+ const selectRef = useRef(select);
+ selectRef.current = select;
+ const appliedSelect = useRef(select);
+ // Response data before applying the selector - allows to re-run it when the selector changes
+ const rawData = useRef | null>(null);
+ // Data set with the setData action has the selected shape and should not get re-selected
+ const isLocalData = useRef(false);
+ const selectError = useRef<{ error: unknown } | null>(null);
+ const responseDetails = useRef(null);
+
+ const selectData = (data: ExtractResponseType | null, previousData: TData | null = null): TData | null => {
+ if (!selectRef.current || data === null || data === undefined) {
+ return data as TData | null;
+ }
+ const selected = selectRef.current(data);
+ return structuralSharing ? replaceEqualDeep(previousData, selected) : selected;
+ };
+
+ const getSelectedState = (newState: UseTrackedStateType): UseTrackedStateType => {
+ rawData.current = newState.data;
+ isLocalData.current = false;
+ responseDetails.current = null;
+ appliedSelect.current = selectRef.current;
+ return { ...newState, data: selectData(newState.data, state.current?.data) };
+ };
+
+ if (!state.current) {
+ state.current = getSelectedState(getInitialState({ initialResponse, dispatcher, request, disabled }));
+ }
+
+ // Selector failed while processing incoming data - surface it to the closest error boundary
+ if (selectError.current) {
+ const { error } = selectError.current;
+ selectError.current = null;
+ throw error;
+ }
+
+ // Selector changed (e.g. it closes over a new prop) - re-run it against the latest response data
+ if (appliedSelect.current !== select && !isLocalData.current) {
+ appliedSelect.current = select;
+ state.current.data = selectData(rawData.current, state.current.data);
+ }
+
// ******************
// useSyncExternalStore
// ******************
@@ -89,12 +139,12 @@ export const useTrackedState = ({
// Dependency Tracking
// ******************
- const renderKeyTrigger = (keys: (keyof UseTrackedStateType)[]) => {
+ const renderKeyTrigger = (keys: (keyof UseTrackedStateType)[]) => {
const shouldRerender = renderKeys.current.some((renderKey) => keys.includes(renderKey));
if (shouldRerender) {emitChange();}
};
- const setRenderKey = (renderKey: keyof UseTrackedStateType) => {
+ const setRenderKey = (renderKey: keyof UseTrackedStateType) => {
if (!renderKeys.current.includes(renderKey)) {
renderKeys.current.push(renderKey);
}
@@ -119,8 +169,8 @@ export const useTrackedState = ({
if (shouldClear && !cacheState) {
// Reset state to initial values when no cached data exists for the new key
const resetState = getInitialState({ initialResponse, dispatcher, request, disabled, revalidate });
- state.current = resetState;
- renderKeyTrigger(Object.keys(state.current) as (keyof UseTrackedStateType)[]);
+ state.current = getSelectedState(resetState);
+ renderKeyTrigger(Object.keys(state.current) as (keyof UseTrackedStateType)[]);
} else if (cacheState) {
state.current.loading = getIsInitiallyLoading({
queryKey: scopeKey(request.queryKey, request.scope),
@@ -162,6 +212,9 @@ export const useTrackedState = ({
// ******************
const handleCompare = (firstValue: unknown, secondValue: unknown) => {
+ if (Object.is(firstValue, secondValue)) {
+ return true;
+ }
if (typeof deepCompare === "function") {
return deepCompare(firstValue, secondValue);
}
@@ -174,8 +227,27 @@ export const useTrackedState = ({
const handleCacheData = (
cacheData: CacheValueType, ExtractErrorType, ExtractAdapterType>,
) => {
- const newStateValues: UseTrackedStateType = {
- data: cacheData.data,
+ rawData.current = cacheData.data;
+ isLocalData.current = false;
+ appliedSelect.current = selectRef.current;
+ responseDetails.current = {
+ willRetry: !!cacheData.willRetry,
+ isCanceled: !!cacheData.isCanceled,
+ isOffline: !!cacheData.isOffline,
+ };
+
+ let data: TData | null;
+ try {
+ data = selectData(cacheData.data, state.current.data);
+ } catch (error) {
+ // Never throw inside of the cache events - rethrow it on render instead
+ selectError.current = { error };
+ emitChange();
+ return;
+ }
+
+ const newStateValues: UseTrackedStateType = {
+ data,
error: cacheData.error,
status: cacheData.status,
success: cacheData.success,
@@ -186,18 +258,17 @@ export const useTrackedState = ({
loading: dispatcher.hasRunningRequests(queryKey),
};
- const changedKeys = Object.keys(newStateValues).filter((key) => {
- const keyValue = key as keyof UseTrackedStateType;
- const firstValue = state.current[keyValue];
- const secondValue = newStateValues[keyValue];
+ const nextState: UseTrackedStateType = { ...state.current };
+ const changedKeys = (Object.keys(newStateValues) as (keyof UseTrackedStateType)[]).filter((key) => {
+ const isEqualValue = handleCompare(state.current[key], newStateValues[key]);
+ // Equal values keep their previous reference, so they stay stable between renders
+ if (!isEqualValue) {
+ (nextState as Record)[key] = newStateValues[key];
+ }
+ return !isEqualValue;
+ });
- return !handleCompare(firstValue, secondValue);
- }) as unknown as (keyof UseTrackedStateType)[];
-
- state.current = {
- ...state.current,
- ...newStateValues,
- };
+ state.current = nextState;
renderKeyTrigger(changedKeys);
};
@@ -253,8 +324,9 @@ export const useTrackedState = ({
// Actions
// ******************
- const actions: UseTrackedStateActions = {
+ const actions: UseTrackedStateActions = {
setData: (data) => {
+ isLocalData.current = true;
state.current.data = data instanceof Function ? data(state.current.data || null) : data;
renderKeyTrigger(["data"]);
},
@@ -313,6 +385,9 @@ export const useTrackedState = ({
renderKeyTrigger(["requestTimestamp"]);
},
clearState: () => {
+ rawData.current = null;
+ isLocalData.current = false;
+ responseDetails.current = null;
state.current = {
data: null,
error: null,
@@ -323,10 +398,21 @@ export const useTrackedState = ({
retries: 0,
responseTimestamp: null,
requestTimestamp: null,
- } as UseTrackedStateType;
- renderKeyTrigger(Object.keys(state.current) as (keyof UseTrackedStateType)[]);
+ } as UseTrackedStateType;
+ renderKeyTrigger(Object.keys(state.current) as (keyof UseTrackedStateType)[]);
},
};
- return [state.current, actions, { setRenderKey, setCacheData, getStaleStatus, getIsDataProcessing }];
+ return [
+ state.current,
+ actions,
+ {
+ setRenderKey,
+ setCacheData,
+ getStaleStatus,
+ getIsDataProcessing,
+ selectData: (data) => selectData(data, state.current.data),
+ getResponseDetails: () => responseDetails.current,
+ },
+ ];
};
diff --git a/packages/react/src/helpers/use-tracked-state/use-tracked-state.types.ts b/packages/react/src/helpers/use-tracked-state/use-tracked-state.types.ts
index 2498b358..1cfdbcab 100644
--- a/packages/react/src/helpers/use-tracked-state/use-tracked-state.types.ts
+++ b/packages/react/src/helpers/use-tracked-state/use-tracked-state.types.ts
@@ -9,12 +9,13 @@ import type {
ExtractAdapterExtraType,
ExtractAdapterResolvedType,
NullableType,
+ ResponseDetailsType,
CacheSetState,
LoggerMethods,
} from "@hyper-fetch/core";
import type { isEqual } from "utils";
-export type UseTrackedStateProps = {
+export type UseTrackedStateProps> = {
request: T;
logger: LoggerMethods;
initialResponse: NullableType>>;
@@ -24,24 +25,40 @@ export type UseTrackedStateProps = {
keepPreviousData?: "auto" | "preserve" | "clean";
disabled?: boolean;
revalidate?: boolean;
+ select?: UseTrackedStateSelect;
+ structuralSharing?: boolean;
};
-export type UseTrackedStateReturn = [
- UseTrackedStateType,
- UseTrackedStateActions,
+/**
+ * Selector picking (or transforming) the part of the response data used by the component
+ */
+export type UseTrackedStateSelect> = (
+ data: ExtractResponseType,
+) => TData;
+
+export type UseTrackedStateReturn> = [
+ UseTrackedStateType,
+ UseTrackedStateActions,
{
- setRenderKey: (renderKey: keyof UseTrackedStateType) => void;
+ setRenderKey: (renderKey: keyof UseTrackedStateType) => void;
setCacheData: (cacheData: CacheValueType, ExtractErrorType>) => void;
getStaleStatus: () => boolean;
getIsDataProcessing: (cacheKey: string) => boolean;
+ selectData: (data: ExtractResponseType | null) => TData | null;
+ /**
+ * Details of the last response received from the cache - null when the state did not come from a response
+ */
+ getResponseDetails: () => UseTrackedStateResponseDetails | null;
},
];
-export type UseTrackedStateType = {
+export type UseTrackedStateResponseDetails = Pick;
+
+export type UseTrackedStateType> = {
/**
* Request response data
*/
- data: null | ExtractResponseType;
+ data: null | TData;
/**
* Request response error
*/
@@ -76,12 +93,13 @@ export type UseTrackedStateType = {
requestTimestamp: null | Date;
};
-export type UseTrackedStateActions = {
+export type UseTrackedStateActions> = {
/**
* Action to set custom data. We can do it locally(inside hook state).
+ * When `select` is used it receives the selected data shape.
* If you need to update cache data use client.cache.update(). method.
*/
- setData: (data: CacheSetState | null>) => void;
+ setData: (data: CacheSetState) => void;
/**
* Action to set custom error. We can do it locally(inside hook state).
* If you need to update cache data use client.cache.update() method.
diff --git a/packages/react/src/hooks/use-cache/use-cache.constants.ts b/packages/react/src/hooks/use-cache/use-cache.constants.ts
index 27f764f7..5716ed39 100644
--- a/packages/react/src/hooks/use-cache/use-cache.constants.ts
+++ b/packages/react/src/hooks/use-cache/use-cache.constants.ts
@@ -2,4 +2,5 @@ export const useCacheDefaultOptions = {
dependencyTracking: true,
initialResponse: null,
deepCompare: true,
+ structuralSharing: true,
};
diff --git a/packages/react/src/hooks/use-cache/use-cache.hooks.ts b/packages/react/src/hooks/use-cache/use-cache.hooks.ts
index b026ce5f..8dac4e74 100644
--- a/packages/react/src/hooks/use-cache/use-cache.hooks.ts
+++ b/packages/react/src/hooks/use-cache/use-cache.hooks.ts
@@ -1,5 +1,5 @@
import { useDidUpdate } from "@better-hooks/lifecycle";
-import type { RequestInstance } from "@hyper-fetch/core";
+import type { ExtractResponseType, RequestInstance } from "@hyper-fetch/core";
import { getRequestDispatcher } from "@hyper-fetch/core";
import { useRequestEvents, useTrackedState } from "helpers";
import type { UseCacheOptionsType, UseCacheReturnType } from "hooks/use-cache";
@@ -9,10 +9,10 @@ import { useRef } from "react";
import { createTrackedProxy } from "utils";
/** Subscribe to cache updates for a given request without triggering network activity. */
-export const useCache = (
+export const useCache = >(
request: T,
- options?: UseCacheOptionsType,
-): UseCacheReturnType => {
+ options?: UseCacheOptionsType,
+): UseCacheReturnType => {
const { cacheKey, client } = request;
const { cache, loggerManager } = client;
@@ -22,7 +22,7 @@ export const useCache = (
// Build the configuration options
const { config: globalConfig } = useProvider();
- const { dependencyTracking, initialResponse, deepCompare } = {
+ const { dependencyTracking, initialResponse, deepCompare, structuralSharing } = {
...useCacheDefaultOptions,
...globalConfig.useCacheConfig,
...options,
@@ -31,13 +31,15 @@ export const useCache = (
/**
* State handler with optimization for rerendering, that hooks into the cache state and dispatchers queues
*/
- const [state, actions, { setRenderKey, setCacheData, getIsDataProcessing }] = useTrackedState({
+ const [state, actions, { setRenderKey, setCacheData, getIsDataProcessing }] = useTrackedState({
logger,
request,
dispatcher,
initialResponse,
deepCompare,
dependencyTracking,
+ select: options?.select,
+ structuralSharing,
});
/**
@@ -103,5 +105,5 @@ export const useCache = (
},
trackedKeys,
setRenderKey,
- ) as UseCacheReturnType;
+ ) as UseCacheReturnType;
};
diff --git a/packages/react/src/hooks/use-cache/use-cache.types.ts b/packages/react/src/hooks/use-cache/use-cache.types.ts
index 961ebbad..353c8c13 100644
--- a/packages/react/src/hooks/use-cache/use-cache.types.ts
+++ b/packages/react/src/hooks/use-cache/use-cache.types.ts
@@ -1,8 +1,8 @@
import type { RequestInstance, ExtractResponseType, ExtractErrorType, CacheValueType } from "@hyper-fetch/core";
-import type { UseTrackedStateActions, UseTrackedStateType } from "helpers";
+import type { UseTrackedStateActions, UseTrackedStateSelect, UseTrackedStateType } from "helpers";
import type { isEqual } from "utils";
-export type UseCacheOptionsType = {
+export type UseCacheOptionsType> = {
/**
* If `true` it will rerender only when values used by our component gets changed. Otherwise it will rerender on any change.
*/
@@ -15,10 +15,22 @@ export type UseCacheOptionsType = {
* Deep comparison function for hook to check for equality in incoming data, to limit rerenders.
*/
deepCompare?: boolean | typeof isEqual;
+ /**
+ * Pick or transform the part of the cached data used by the component.
+ * The hook rerenders only when the selected value changes, while the cache stays shared with other hooks.
+ */
+ select?: UseTrackedStateSelect;
+ /**
+ * Keep references of the unchanged parts of the selected data between updates (default true).
+ */
+ structuralSharing?: boolean;
};
-export type UseCacheReturnType = UseTrackedStateType &
- UseTrackedStateActions & {
+export type UseCacheReturnType> = UseTrackedStateType<
+ T,
+ TSelected
+> &
+ UseTrackedStateActions & {
/**
* Invalidate cache for the current request or pass custom key to trigger it by invalidationKey(Regex / cacheKey).
*/
diff --git a/packages/react/src/hooks/use-emitter/use-emitter.hooks.ts b/packages/react/src/hooks/use-emitter/use-emitter.hooks.ts
index 52f0325c..cfc8561a 100644
--- a/packages/react/src/hooks/use-emitter/use-emitter.hooks.ts
+++ b/packages/react/src/hooks/use-emitter/use-emitter.hooks.ts
@@ -12,7 +12,7 @@ export const useEmitter = (
options?: UseEmitterOptionsType,
) => {
const { config: globalConfig } = useProvider();
- const { dependencyTracking } = { ...globalConfig.useEmitter, ...options };
+ const { dependencyTracking = true } = { ...globalConfig.useEmitter, ...options };
const [state, actions, callbacks, { setRenderKey }] = useSocketState(emitter.socket, { dependencyTracking });
diff --git a/packages/react/src/hooks/use-fetch/use-fetch.constants.ts b/packages/react/src/hooks/use-fetch/use-fetch.constants.ts
index 7f73abfb..c4195af4 100644
--- a/packages/react/src/hooks/use-fetch/use-fetch.constants.ts
+++ b/packages/react/src/hooks/use-fetch/use-fetch.constants.ts
@@ -2,7 +2,7 @@ import type { RequestInstance, RequiredKeys } from "@hyper-fetch/core";
import { Time } from "@hyper-fetch/core";
import type { UseFetchOptionsType } from "hooks/use-fetch";
-type DefaultOptionsType = RequiredKeys, "initialResponse">> & {
+type DefaultOptionsType = RequiredKeys, "initialResponse" | "select">> & {
initialResponse: null;
};
@@ -25,4 +25,6 @@ export const useFetchDefaultOptions: DefaultOptionsType = {
bounceTime: 400,
bounceTimeout: 400,
deepCompare: true,
+ structuralSharing: true,
+ throwOnError: false,
};
diff --git a/packages/react/src/hooks/use-fetch/use-fetch.hooks.ts b/packages/react/src/hooks/use-fetch/use-fetch.hooks.ts
index a55e3eb5..09011abd 100644
--- a/packages/react/src/hooks/use-fetch/use-fetch.hooks.ts
+++ b/packages/react/src/hooks/use-fetch/use-fetch.hooks.ts
@@ -5,6 +5,8 @@ import type {
ExtractAdapterStatusType,
ExtractAdapterType,
ExtractAdapterExtraType,
+ ExtractErrorType,
+ ExtractResponseType,
} from "@hyper-fetch/core";
import { scopeKey } from "@hyper-fetch/core";
import { useRequestEvents, useTrackedState } from "helpers";
@@ -25,10 +27,10 @@ const suspenseResultMap = new Map(
+export const useFetch = >(
request: R,
- options?: UseFetchOptionsType,
-): UseFetchReturnType => {
+ options?: UseFetchOptionsType,
+): UseFetchReturnType => {
// Build the configuration options
const { config: globalConfig } = useProvider();
const {
@@ -50,6 +52,8 @@ export const useFetch = (
bounceTime,
bounceTimeout,
deepCompare,
+ structuralSharing,
+ throwOnError,
} = {
...useFetchDefaultOptions,
...globalConfig.useFetchConfig,
@@ -72,7 +76,11 @@ export const useFetch = (
/**
* State handler with optimization for re-rendering, that hooks into the cache state and dispatchers queues
*/
- const [state, actions, { setRenderKey, setCacheData, getStaleStatus, getIsDataProcessing }] = useTrackedState({
+ const [
+ state,
+ actions,
+ { setRenderKey, setCacheData, getStaleStatus, getIsDataProcessing, selectData, getResponseDetails },
+ ] = useTrackedState({
logger,
request: request as unknown as R,
dispatcher,
@@ -82,6 +90,8 @@ export const useFetch = (
keepPreviousData,
disabled,
revalidate,
+ select: options?.select,
+ structuralSharing,
});
/**
@@ -273,7 +283,7 @@ export const useFetch = (
const suspenseResult = suspenseResultMap.get(cacheKey);
if (suspenseResult) {
suspenseResultMap.delete(cacheKey);
- if (suspenseResult.data !== null) {state.data = suspenseResult.data;}
+ if (suspenseResult.data !== null) {state.data = selectData(suspenseResult.data);}
if (suspenseResult.error !== null) {state.error = suspenseResult.error;}
if (suspenseResult.status !== null) {state.status = suspenseResult.status;}
if (suspenseResult.extra !== null) {state.extra = suspenseResult.extra;}
@@ -324,6 +334,28 @@ export const useFetch = (
}
}
+ // ******************
+ // Error boundary
+ // ******************
+ if (throwOnError) {
+ // The error has to be observed to rerender (and throw) even if the component does not read it
+ setRenderKey("error");
+ setRenderKey("loading");
+ setRenderKey("success");
+
+ const details = getResponseDetails();
+ // Errors of retried, canceled or offline (re-queued) requests are not final
+ const isSettled = !state.loading && !details?.willRetry && !details?.isCanceled && !details?.isOffline;
+ const hasError = !state.success && state.error !== null && state.error !== undefined;
+
+ if (isSettled && hasError) {
+ const error = state.error as ExtractErrorType;
+ if (typeof throwOnError === "function" ? throwOnError(error) : throwOnError) {
+ throw error;
+ }
+ }
+ }
+
const trackedKeys = [
"data",
"error",
@@ -354,5 +386,5 @@ export const useFetch = (
},
trackedKeys,
setRenderKey,
- ) as UseFetchReturnType;
+ ) as UseFetchReturnType;
};
diff --git a/packages/react/src/hooks/use-fetch/use-fetch.types.ts b/packages/react/src/hooks/use-fetch/use-fetch.types.ts
index e2a9dd62..7712fab9 100644
--- a/packages/react/src/hooks/use-fetch/use-fetch.types.ts
+++ b/packages/react/src/hooks/use-fetch/use-fetch.types.ts
@@ -1,5 +1,16 @@
-import type { RequestInstance, NullableType, ExtractAdapterResolvedType } from "@hyper-fetch/core";
-import type { UseRequestEventsActionsType, UseTrackedStateActions, UseTrackedStateType } from "helpers";
+import type {
+ RequestInstance,
+ NullableType,
+ ExtractAdapterResolvedType,
+ ExtractErrorType,
+ ExtractResponseType,
+} from "@hyper-fetch/core";
+import type {
+ UseRequestEventsActionsType,
+ UseTrackedStateActions,
+ UseTrackedStateSelect,
+ UseTrackedStateType,
+} from "helpers";
import type { isEqual } from "utils";
// export type UseFetchCastRequest = ExtendRequest<
@@ -13,7 +24,7 @@ import type { isEqual } from "utils";
// export type UseFetchRequest = R extends UseFetchCastRequest ? R : UseFetchCastRequest;
-export type UseFetchOptionsType = {
+export type UseFetchOptionsType> = {
/**
* Enable React Suspense mode. When true, the hook throws a Promise while loading,
* which is caught by the nearest Suspense boundary. The component re-renders
@@ -81,6 +92,20 @@ export type UseFetchOptionsType = {
* Deep comparison function for hook to check for equality in incoming data, to limit rerenders.
*/
deepCompare?: boolean | typeof isEqual;
+ /**
+ * Pick or transform the part of the response data used by the component.
+ * The hook rerenders only when the selected value changes, while the cache stays shared with other hooks.
+ */
+ select?: UseTrackedStateSelect;
+ /**
+ * Keep references of the unchanged parts of the selected data between updates (default true).
+ */
+ structuralSharing?: boolean;
+ /**
+ * Throw the request error to the closest React Error Boundary once the request settles with an error.
+ * Pass a function to decide per error.
+ */
+ throwOnError?: boolean | ((error: ExtractErrorType) => boolean);
} & (
| {
/**
@@ -112,8 +137,11 @@ export type UseFetchOptionsType = {
}
);
-export type UseFetchReturnType = UseTrackedStateType &
- UseTrackedStateActions &
+export type UseFetchReturnType> = UseTrackedStateType<
+ R,
+ TSelected
+> &
+ UseTrackedStateActions &
UseRequestEventsActionsType & {
/**
* Data related to current state of the bounce usage
diff --git a/packages/react/src/hooks/use-is-fetching/index.ts b/packages/react/src/hooks/use-is-fetching/index.ts
new file mode 100644
index 00000000..d24aa841
--- /dev/null
+++ b/packages/react/src/hooks/use-is-fetching/index.ts
@@ -0,0 +1,2 @@
+export * from "./use-is-fetching.hooks";
+export * from "./use-is-fetching.types";
diff --git a/packages/react/src/hooks/use-is-fetching/use-is-fetching.hooks.ts b/packages/react/src/hooks/use-is-fetching/use-is-fetching.hooks.ts
new file mode 100644
index 00000000..8d98c41b
--- /dev/null
+++ b/packages/react/src/hooks/use-is-fetching/use-is-fetching.hooks.ts
@@ -0,0 +1,51 @@
+import type { AdapterInstance, ClientInstance, Dispatcher } from "@hyper-fetch/core";
+import type { UseIsFetchingOptionsType } from "hooks/use-is-fetching";
+import { useCallback, useRef, useSyncExternalStore } from "react";
+
+const useRunningRequestsCount = (
+ dispatcher: Dispatcher,
+ options?: UseIsFetchingOptionsType,
+): number => {
+ const optionsRef = useRef(options);
+ optionsRef.current = options;
+
+ const subscribe = useCallback(
+ (listener: () => void) => dispatcher.events.onRunningRequestsChange(listener),
+ [dispatcher],
+ );
+
+ const getCount = () => {
+ const { queryKey, predicate } = optionsRef.current || {};
+ return dispatcher.getAllRunningRequests().filter(({ request }) => {
+ if (typeof queryKey === "string" && request.queryKey !== queryKey) {
+ return false;
+ }
+ if (queryKey instanceof RegExp && !queryKey.test(request.queryKey)) {
+ return false;
+ }
+ return !predicate || predicate(request);
+ }).length;
+ };
+
+ return useSyncExternalStore(subscribe, getCount, getCount);
+};
+
+/**
+ * Count of the currently running fetch requests (useFetch, useCache and fetch dispatcher). Useful for global loading indicators.
+ * @param client Client instance
+ * @param options Filters for the counted requests
+ * @returns number of running requests
+ */
+export const useIsFetching = (client: ClientInstance, options?: UseIsFetchingOptionsType): number => {
+ return useRunningRequestsCount(client.fetchDispatcher, options);
+};
+
+/**
+ * Count of the currently running submit requests (useSubmit and submit dispatcher). Useful for global saving indicators.
+ * @param client Client instance
+ * @param options Filters for the counted requests
+ * @returns number of running requests
+ */
+export const useIsSubmitting = (client: ClientInstance, options?: UseIsFetchingOptionsType): number => {
+ return useRunningRequestsCount(client.submitDispatcher, options);
+};
diff --git a/packages/react/src/hooks/use-is-fetching/use-is-fetching.types.ts b/packages/react/src/hooks/use-is-fetching/use-is-fetching.types.ts
new file mode 100644
index 00000000..d29d2def
--- /dev/null
+++ b/packages/react/src/hooks/use-is-fetching/use-is-fetching.types.ts
@@ -0,0 +1,12 @@
+import type { RequestInstance } from "@hyper-fetch/core";
+
+export type UseIsFetchingOptionsType = {
+ /**
+ * Count only the requests with matching queryKey - exact string or RegExp
+ */
+ queryKey?: string | RegExp;
+ /**
+ * Count only the requests for which the predicate returns true
+ */
+ predicate?: (request: RequestInstance) => boolean;
+};
diff --git a/packages/react/src/hooks/use-listener/use-listener.hooks.ts b/packages/react/src/hooks/use-listener/use-listener.hooks.ts
index adf6c423..4f16f171 100644
--- a/packages/react/src/hooks/use-listener/use-listener.hooks.ts
+++ b/packages/react/src/hooks/use-listener/use-listener.hooks.ts
@@ -1,4 +1,5 @@
import { useDidUpdate, useWillUnmount } from "@better-hooks/lifecycle";
+import { replaceEqualDeep } from "@hyper-fetch/core";
import type { ListenerInstance, ExtractListenerDeliveredType } from "@hyper-fetch/sockets";
import { getDeliveryKey } from "@hyper-fetch/sockets";
import { useSocketState } from "helpers";
@@ -8,18 +9,56 @@ import { useRef } from "react";
import { createTrackedProxy } from "utils";
/** Subscribe to incoming socket messages on a given listener topic with tracked connection state and callbacks. */
-export const useListener = (
+export const useListener = <
+ ListenerType extends ListenerInstance,
+ TSelected = ExtractListenerDeliveredType,
+>(
listener: ListenerType,
- options?: UseListenerOptionsType,
+ options?: UseListenerOptionsType, TSelected>,
) => {
const { config: globalConfig } = useProvider();
- const { dependencyTracking } = { ...globalConfig.useListener, ...options };
- const [state, actions, callbacks, { setRenderKey }] = useSocketState<
- ExtractListenerDeliveredType,
+ const { dependencyTracking = true, structuralSharing = true } = { ...globalConfig.useListener, ...options };
+ const select = options?.select;
+ const [state, actions, callbacks, { setRenderKey, getState, rerender }] = useSocketState<
+ TSelected,
ListenerType["socket"]
>(listener.socket, { dependencyTracking });
const removeListenerRef = useRef | null>(null);
+ /**
+ * Selection
+ */
+
+ const selectRef = useRef(select);
+ selectRef.current = select;
+ const appliedSelect = useRef(select);
+ // Last message data before applying the selector - allows to re-run it when the selector changes
+ const rawData = useRef<{ data: ExtractListenerDeliveredType } | null>(null);
+ const selectError = useRef<{ error: unknown } | null>(null);
+
+ const selectData = (data: ExtractListenerDeliveredType): TSelected => {
+ if (!selectRef.current) {
+ return data as unknown as TSelected;
+ }
+ const selected = selectRef.current(data);
+ return structuralSharing ? replaceEqualDeep(getState().data, selected) : selected;
+ };
+
+ // Selector failed while processing a message - surface it to the closest error boundary
+ if (selectError.current) {
+ const { error } = selectError.current;
+ selectError.current = null;
+ throw error;
+ }
+
+ // Selector changed (e.g. it closes over a new prop) - re-run it against the last message
+ if (appliedSelect.current !== select) {
+ appliedSelect.current = select;
+ if (rawData.current) {
+ state.data = selectData(rawData.current.data);
+ }
+ }
+
/**
* Callbacks
*/
@@ -40,7 +79,23 @@ export const useListener = (
stopListener();
removeListenerRef.current = listener.listen(({ data, extra }) => {
onEventCallback.current?.({ data, extra });
- actions.setData(data);
+ rawData.current = { data };
+ appliedSelect.current = selectRef.current;
+
+ let selected: TSelected;
+ try {
+ selected = selectData(data);
+ } catch (error) {
+ // Never throw inside of the socket events - rethrow it on render instead
+ selectError.current = { error };
+ rerender();
+ return;
+ }
+
+ // With a selector, data is updated only when the selected value changed
+ if (!selectRef.current || !Object.is(selected, getState().data)) {
+ actions.setData(selected);
+ }
actions.setExtra(extra);
actions.setTimestamp(Date.now());
});
diff --git a/packages/react/src/hooks/use-listener/use-listener.types.ts b/packages/react/src/hooks/use-listener/use-listener.types.ts
index fda3e40b..b6915eda 100644
--- a/packages/react/src/hooks/use-listener/use-listener.types.ts
+++ b/packages/react/src/hooks/use-listener/use-listener.types.ts
@@ -1,4 +1,15 @@
-export type UseListenerOptionsType = {
+export type UseListenerOptionsType = {
/** When true (default), only re-renders when accessed state properties change */
dependencyTracking?: boolean;
+ /**
+ * Pick or transform the part of the message data used by the component.
+ * With a selector the `data` value updates only when the selected value changes,
+ * so components reading `data` rerender only for the relevant messages.
+ */
+ select?: (data: DataType) => TSelected;
+ /**
+ * Keep references of the unchanged parts of the selected data between messages (default true).
+ * Applies to the `select` result - without a selector every message updates the data.
+ */
+ structuralSharing?: boolean;
};
diff --git a/packages/react/src/hooks/use-submit/use-submit.constants.ts b/packages/react/src/hooks/use-submit/use-submit.constants.ts
index 7e553855..c5847588 100644
--- a/packages/react/src/hooks/use-submit/use-submit.constants.ts
+++ b/packages/react/src/hooks/use-submit/use-submit.constants.ts
@@ -2,7 +2,7 @@ import type { RequestInstance, RequiredKeys } from "@hyper-fetch/core";
import type { UseSubmitOptionsType } from "./use-submit.types";
-type DefaultOptionsType = RequiredKeys, "initialResponse">> & {
+type DefaultOptionsType = RequiredKeys, "initialResponse" | "select">> & {
initialResponse: null;
};
@@ -14,4 +14,5 @@ export const useSubmitDefaultOptions: DefaultOptionsType = {
bounceType: "debounce",
bounceTime: 400,
deepCompare: true,
+ structuralSharing: true,
};
diff --git a/packages/react/src/hooks/use-submit/use-submit.hooks.ts b/packages/react/src/hooks/use-submit/use-submit.hooks.ts
index 777f086a..8dc62ed6 100644
--- a/packages/react/src/hooks/use-submit/use-submit.hooks.ts
+++ b/packages/react/src/hooks/use-submit/use-submit.hooks.ts
@@ -25,13 +25,13 @@ import { createTrackedProxy, getBounceData } from "utils";
* @param options
* @returns
*/
-export const useSubmit = (
+export const useSubmit = >(
request: RequestType,
- options?: UseSubmitOptionsType,
-): UseSubmitReturnType => {
+ options?: UseSubmitOptionsType,
+): UseSubmitReturnType => {
// Build the configuration options
const { config: globalConfig } = useProvider();
- const mergedOptions: UseSubmitOptionsType = useMemo(
+ const mergedOptions: UseSubmitOptionsType = useMemo(
() => ({
...useSubmitDefaultOptions,
...globalConfig.useSubmitConfig,
@@ -40,7 +40,16 @@ export const useSubmit = (
// eslint-disable-next-line react-hooks/exhaustive-deps
[globalConfig.useSubmitConfig, JSON.stringify(options), options?.deepCompare],
);
- const { disabled, dependencyTracking, initialResponse, bounce, bounceType, bounceTime, deepCompare } = mergedOptions;
+ const {
+ disabled,
+ dependencyTracking,
+ initialResponse,
+ bounce,
+ bounceType,
+ bounceTime,
+ deepCompare,
+ structuralSharing,
+ } = mergedOptions;
/**
* Because of the dynamic cacheKey / queryKey signing within the request we need to store it's latest instance
@@ -71,13 +80,19 @@ export const useSubmit = (
/**
* State handler with optimization for rerendering, that hooks into the cache state and dispatchers queues
*/
- const [state, actions, { setRenderKey, setCacheData, getIsDataProcessing }] = useTrackedState({
+ const [state, actions, { setRenderKey, setCacheData, getIsDataProcessing }] = useTrackedState<
+ RequestType,
+ TSelected
+ >({
logger,
request,
dispatcher,
initialResponse,
deepCompare: deepCompare as boolean,
dependencyTracking: dependencyTracking as boolean,
+ // Read directly from options - functions are not part of the memoized options key
+ select: options?.select,
+ structuralSharing,
});
/**
@@ -239,5 +254,5 @@ export const useSubmit = (
},
trackedKeys,
setSubmitRenderKey,
- ) as UseSubmitReturnType;
+ ) as UseSubmitReturnType;
};
diff --git a/packages/react/src/hooks/use-submit/use-submit.types.ts b/packages/react/src/hooks/use-submit/use-submit.types.ts
index 42358f09..5e208d3b 100644
--- a/packages/react/src/hooks/use-submit/use-submit.types.ts
+++ b/packages/react/src/hooks/use-submit/use-submit.types.ts
@@ -1,4 +1,10 @@
-import type { ExtractAdapterResolvedType, NullableType, RequestInstance, RequestSendType } from "@hyper-fetch/core";
+import type {
+ ExtractAdapterResolvedType,
+ ExtractResponseType,
+ NullableType,
+ RequestInstance,
+ RequestSendType,
+} from "@hyper-fetch/core";
import type {
OnProgressCallbackType,
OnStartCallbackType,
@@ -7,10 +13,11 @@ import type {
OnFinishedCallbackType,
UseTrackedStateType,
UseTrackedStateActions,
+ UseTrackedStateSelect,
} from "helpers";
import type { isEqual } from "utils";
-export type UseSubmitOptionsType = {
+export type UseSubmitOptionsType> = {
/**
* Disable submitting
*/
@@ -31,6 +38,15 @@ export type UseSubmitOptionsType = {
* Deep comparison function for hook to check for equality in incoming data, to limit rerenders.
*/
deepCompare?: boolean | typeof isEqual;
+ /**
+ * Pick or transform the part of the response data used by the component.
+ * Callbacks like `onSubmitSuccess` still receive the full response.
+ */
+ select?: UseTrackedStateSelect;
+ /**
+ * Keep references of the unchanged parts of the selected data between updates (default true).
+ */
+ structuralSharing?: boolean;
} & (
| {
/**
@@ -58,11 +74,11 @@ export type UseSubmitOptionsType = {
}
);
-export type UseSubmitReturnType = Omit<
- UseTrackedStateType,
- "loading"
-> &
- UseTrackedStateActions & {
+export type UseSubmitReturnType<
+ RequestType extends RequestInstance,
+ TSelected = ExtractResponseType,
+> = Omit, "loading"> &
+ UseTrackedStateActions & {
/**
* Callback which allows to cancel ongoing requests from given queryKey.
*/
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 5feb9cfd..eb2f6c90 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -6,6 +6,7 @@ export * from "./hooks/use-stream";
export * from "./hooks/use-listener";
export * from "./hooks/use-emitter";
export * from "./hooks/use-app-manager";
+export * from "./hooks/use-is-fetching";
export * from "./helpers";
export * from "./provider";
export * from "./utils";
diff --git a/packages/react/src/provider/provider.types.ts b/packages/react/src/provider/provider.types.ts
index 768b8358..9fff1757 100644
--- a/packages/react/src/provider/provider.types.ts
+++ b/packages/react/src/provider/provider.types.ts
@@ -7,12 +7,16 @@ import type { UseQueueOptionsType } from "hooks/use-queue";
import type { UseSubmitOptionsType } from "hooks/use-submit";
import type React from "react";
+// Omit applied to every union member - keeps the bounce options discriminated union intact
+type DistributiveOmit = T extends unknown ? Omit : never;
+
export type ProviderOptionsType = {
- useFetchConfig?: Partial>;
- useSubmitConfig?: Partial>;
- useCacheConfig?: Partial>;
+ // Selectors are component specific, they cannot be configured globally
+ useFetchConfig?: Partial, "select">>;
+ useSubmitConfig?: Partial, "select">>;
+ useCacheConfig?: Partial, "select">>;
useQueueConfig?: Partial;
- useListener?: Partial;
+ useListener?: Partial>;
useEmitter?: Partial;
};