Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions documentation/docs/core/cache.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down
14 changes: 14 additions & 0 deletions documentation/docs/core/dispatcher.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
1 change: 1 addition & 0 deletions documentation/docs/core/request.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down
19 changes: 19 additions & 0 deletions documentation/docs/react/04-hooks/use-cache.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <span>{doneCount}</span>;
}
```

---

## Options

Configuration options for this hook provided as a second parameter.
Expand Down
104 changes: 102 additions & 2 deletions documentation/docs/react/04-hooks/use-fetch.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -245,13 +245,54 @@ function App() {

:::info React 18+ Required

Suspense mode requires React 18 or later. You can combine it with `<ErrorBoundary>` components to handle error states
declaratively.
Suspense mode requires React 18 or later. You can combine it with `<ErrorBoundary>` 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 <Table data={data} />;
}

function App() {
return (
<ErrorBoundary fallbackRender={({ resetErrorBoundary }) => <button onClick={resetErrorBoundary}>Retry</button>}>
<Suspense fallback={<p>Loading users...</p>}>
<UsersList />
</Suspense>
</ErrorBoundary>
);
}
```

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
Expand Down Expand Up @@ -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 <span>{price}</span>;
}
```

- 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.
Expand Down Expand Up @@ -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

Expand Down
65 changes: 65 additions & 0 deletions documentation/docs/react/04-hooks/use-is-fetching.mdx
Original file line number Diff line number Diff line change
@@ -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 <Spinner label={submitting ? "Saving..." : "Loading..."} />;
}
```

---

## 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)
13 changes: 13 additions & 0 deletions documentation/docs/react/04-hooks/use-submit.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 29 additions & 0 deletions documentation/docs/react/05-sockets/use-listener.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <span>{price}</span>;
}
```

- `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.
Expand Down
48 changes: 48 additions & 0 deletions packages/core/__tests__/features/cache/cache.base.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any, any>(request.cacheKey)?.data;
cache.set(request, { ...response, ...details, data: { list: [{ id: 1 }], meta: { page: 1 } } });
const second = cache.get<any, any>(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<any, any>(request.cacheKey)?.data;
cache.set(request, { ...response, ...details, data: { list: [{ id: 1 }], meta: { page: 2 } } });
const second = cache.get<any, any>(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<any, any>(request.cacheKey)?.data;
cache.update(request, (prev) => ({ ...prev, data: { ...prev?.data, meta: { page: 3 } } }));
const second = cache.get<any, any>(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<any, any>(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();
Expand Down
Loading
Loading