Skip to content
Merged
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
197 changes: 153 additions & 44 deletions website/blog/2026-10-03-v0.19-batch-set.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ tags: [releases, managers, schema]
draft: true
---

import DiffEditor from '@site/src/components/DiffEditor';
import StackBlitz from '@site/src/components/StackBlitz';
import TypeScriptEditor from '@site/src/components/TypeScriptEditor';
v0.19 lets a [Manager](/docs/concepts/managers) write a whole batch of streamed rows in one store update, and fixes a
round of TypeScript and Vue issues.

**New APIs:**

Expand All @@ -30,48 +29,122 @@ import TypeScriptEditor from '@site/src/components/TypeScriptEditor';
- [Entity](/rest/api/Entity) classes can be used where an `EntityInterface` is expected ([#4149](https://github.com/reactive/data-client/pull/4149)); [prepare `pk()` overrides](/blog/2026/10/03/v0.19-batch-set#entity-pk-args) for a future release
- [useCache()](/docs/api/useCache) and [useDLE()](/docs/api/useDLE) return `undefined` for a deleted entity whose refetch failed, instead of a truthy `Symbol` that slipped past `if (!data)` checks; [remove any workarounds](/blog/2026/10/03/v0.19-batch-set#deleted-entity-undefined) ([#4150](https://github.com/reactive/data-client/pull/4150))
- Vue [useFetch()](/vue/api/useFetch) keeps its data from being garbage collected while mounted, like [useSuspense()](/vue/api/useSuspense), so a configured `gcPolicy` no longer evicts prefetched data that components read later ([#4152](https://github.com/reactive/data-client/pull/4152))
- Vue [useFetch()](/vue/api/useFetch) is typed as the read-only `Ref` it returns, so `promise.resolved` (always `undefined`) is now a TypeScript error; read `promise.value.resolved` instead ([#4114](https://github.com/reactive/data-client/pull/4114))

**Breaking Changes:**
**[Breaking Changes:](/blog/2026/10/03/v0.19-batch-set#migration-guide)**

- TypeScript 4.0 or later is required; the TypeScript 3.x declarations are removed ([#4151](https://github.com/reactive/data-client/pull/4151))
- [TypeScript 4.0 or later is required](/blog/2026/10/03/v0.19-batch-set#typescript-4) - the TypeScript 3.x declarations are removed ([#4151](https://github.com/reactive/data-client/pull/4151))

{/* truncate */}

import DiffEditor from '@site/src/components/DiffEditor';
import HooksPlayground from '@site/src/components/HooksPlayground';
import StackBlitz from '@site/src/components/StackBlitz';
import TypeScriptEditor from '@site/src/components/TypeScriptEditor';

## Batch Controller.set() {#batch-set}

[Controller.set()](/docs/api/Controller#set-array) now accepts an [Array](/rest/api/Array) schema, so a list of rows
is normalized in one store update. Each row merges with its stored entity, and entities not in the list are untouched.
A [Manager](/docs/concepts/managers) that receives a stream of rows (like price tickers over a websocket) can now write
them all with one [Controller.set()](/docs/api/Controller#set-array) by passing an [Array](/rest/api/Array) schema.
Before v0.19, `[Ticker]` was a TypeScript error, so the usual workaround was a `set()` per row:

```ts
// Before: TypeScript error on [Ticker], so batches became one set() per row
for (const row of rows) {
ctrl.set(Ticker, { product_id: row.product_id }, row);
}
<DiffEditor>

```ts title="Before"
ws.onmessage = event => {
const rows = JSON.parse(event.data);
for (const row of rows) {
ctrl.set(Ticker, { product_id: row.product_id }, row);
}
};
```

// After: one store update
ctrl.set([Ticker], rows);
```ts title="After"
ws.onmessage = event => {
const rows = JSON.parse(event.data);
ctrl.set([Ticker], rows);
};
```

This works at runtime before v0.19, but only typechecked for single entities, which pushed code toward a `set()` call
per row or a push-only endpoint with `setResponse()`. Array schemas take no `args` and no updater function.
</DiffEditor>

Each row merges with its stored entity, and entities not in the list are untouched. Array schemas take no `args` and
no updater function. To batch mixed Entity types, deletes, or rows keyed by id, pass a [Union](/rest/api/Union),
[Invalidate](/rest/api/Invalidate#batch-invalidation), or [Values](/rest/api/Values) schema; see
[Controller.set()](/docs/api/Controller#set-array) for examples.
[#4103](https://github.com/reactive/data-client/pull/4103)

Rows are typed by the Entity's fields. Use a [Union](/rest/api/Union) for lists that mix Entity types,
[Invalidate](/rest/api/Invalidate#batch-invalidation) to delete many entities at once, and [Values](/rest/api/Values)
for rows keyed by id.
Try both buttons below. Each writes 500 new prices; the timer shows how long until the update is committed.

```ts
const Message = new schema.Union({ ticker: Ticker, trade: Trade }, 'type');
ctrl.set([Message], messages);
ctrl.set([new schema.Invalidate(Ticker)], [{ product_id: 'BTC-USD' }]);
ctrl.set(new schema.Values(Ticker), { 'BTC-USD': row });
<HooksPlayground row>

```ts title="Ticker" collapsed
import { Entity } from '@data-client/rest';

export class Ticker extends Entity {
product_id = '';
price = 0;

pk() {
return this.product_id;
}
static key = 'Ticker';
}

export const newPrices = () =>
Array.from({ length: 500 }, (_, i) => ({
product_id: `COIN-${i}`,
price: Math.round(Math.random() * 10000) / 100,
}));
```

```tsx title="PriceStream"
import { useController, useQuery } from '@data-client/react';
import { Ticker, newPrices } from './Ticker';

function PriceStream() {
const ctrl = useController();
const [timing, setTiming] = React.useState('');
const first = useQuery(Ticker, { product_id: 'COIN-0' });

const time = async (
label: string,
write: (rows: ReturnType<typeof newPrices>) => Promise<unknown>,
) => {
const rows = newPrices();
const start = performance.now();
await write(rows);
setTiming(`${label}: ${(performance.now() - start).toFixed(1)} ms`);
};
const perRow = () =>
time('500 set() calls', rows =>
Promise.all(
rows.map(row => ctrl.set(Ticker, { product_id: row.product_id }, row)),
),
);
// highlight-next-line
const batch = () => time('1 batch set()', rows => ctrl.set([Ticker], rows));

return (
<div>
<button onClick={perRow}>set() per row</button>{' '}
<button onClick={batch}>batch set()</button>
<p>COIN-0: {first ? `$${first.price}` : 'no data yet'}</p>
<p>{timing}</p>
</div>
);
}
render(<PriceStream />);
```

</HooksPlayground>

### Performance {#batch-set-performance}

Each `set()` is a separate store update, and every store update copies that entity type's table. Writing rows one
at a time repeats that copy for every row, so the cost grows with both the batch size and the store size. A batch
pays it once: **20x** faster for 50 rows and **95x** faster for 500 rows, into a store holding 500 entities.
pays it once, and notifies subscribers once instead of once per row: **20x** faster for 50 rows (10.8 ms to 0.54 ms)
and **95x** faster for 500 rows (103 ms to 1.08 ms), into a store holding 500 entities.
[#4103](https://github.com/reactive/data-client/pull/4103)

<center>
Expand All @@ -80,24 +153,19 @@ pays it once: **20x** faster for 50 rows and **95x** faster for 500 rows, into a

```mermaid
xychart-beta
title "Writing rows into a 500-entity store"
title "Batch set() speedup vs one set() per row"
x-axis ["50 rows", "500 rows"]
y-axis "Milliseconds (lower is better)" 0 --> 105
bar [10.8, 103]
bar [0.54, 1.08]
y-axis "Times faster" 0 --> 100
bar [20, 95]
```

</div>

One `set()` per row vs one `set([Ticker], rows)`. This measures the store update only; a batch also notifies
subscribers once instead of once per row.

[Benchmarks over time](https://reactive.github.io/data-client/dev/bench/) | [View benchmark](https://github.com/reactive/data-client/tree/master/examples/benchmark)

</center>

This is most useful in [Managers](/docs/concepts/managers) that receive high-frequency streams or large snapshots.
Buffer incoming messages and flush each batch with one `set()`, as described in
In a [Manager](/docs/concepts/managers), buffer incoming messages and flush each batch with one `set()`, as described in
[Batching high-frequency updates](/docs/concepts/managers#batching). The coin app's `StreamManager` now
flushes Coinbase ticker messages this way.

Expand Down Expand Up @@ -209,32 +277,54 @@ This applies to every composable that takes endpoint arguments: [useSuspense()](
[useLive()](/vue/api/useLive), [useCache()](/vue/api/useCache), [useDLE()](/vue/api/useDLE),
[useFetch()](/vue/api/useFetch), [useQuery()](/vue/api/useQuery) and [useSubscription()](/vue/api/useSubscription).

### Entity pk() args {#entity-pk-args}
### Entity classes typecheck as EntityInterface {#entity-pk-args}

Helpers typed to accept any Entity with `EntityInterface` rejected Entity classes, because
[Entity.pk()](/rest/api/Entity#pk) typed its `args` as a mutable array. It is now `readonly any[]`, so this
typechecks ([#4149](https://github.com/reactive/data-client/pull/4149)):

[Entity.pk()](/rest/api/Entity#pk) now types its `args` parameter as `readonly any[]`, so Entity classes can be passed
where an `EntityInterface` is expected ([#4149](https://github.com/reactive/data-client/pull/4149)).
<TypeScriptEditor>

```ts
import { Entity } from '@data-client/rest';
import type { EntityInterface } from '@data-client/react';

const schema: EntityInterface = User;
class User extends Entity {
id = '';
name = '';
}

function entityName(schema: EntityInterface) {
return schema.key;
}

entityName(User);
```

:::tip
</TypeScriptEditor>

Overrides of `static pk()` that annotate `args` as a mutable array still compile, but a future breaking release
will require `readonly`. Update them now:
If you override `static pk()` and type `args` as a mutable array, it still compiles, but a future breaking release
will require `readonly`. Update it now:

```ts
<DiffEditor>

```ts title="Before"
class User extends Entity {
static pk(value: any, parent?: any, key?: string, args?: any[]) {
return `${value.id}-${args?.[0]?.org}`;
}
}
```

```ts title="After"
class User extends Entity {
// highlight-next-line
static pk(value: any, parent?: any, key?: string, args?: readonly any[]) {
return `${value.id}-${args?.[0]?.org}`;
}
}
```

:::
</DiffEditor>

### Deleted entities read as undefined {#deleted-entity-undefined}

Expand Down Expand Up @@ -280,3 +370,22 @@ function TodoDetail({ id }: { id: number }) {

To show something specific when the refetch failed (for example a 404 after deletion), read `error` from
[useDLE()](/docs/api/useDLE) rather than inspecting `data`.

## Migration guide

import PkgTabs from '@site/src/components/PkgTabs';

This upgrade requires updating all package versions simultaneously.

<PkgTabs pkgs="@data-client/react@^0.19.0 @data-client/rest@^0.19.0 @data-client/endpoint@^0.19.0 @data-client/core@^0.19.0 @data-client/vue@^0.19.0 @data-client/test@^0.19.0 @data-client/img@^0.19.0" upgrade />

### TypeScript 4.0 or later {#typescript-4}

Skip this section if you already use TypeScript 4.0 or later.

The TypeScript 3.x declarations are removed, since they no longer typechecked on any TypeScript 3.x version. Bump
`typescript` to `^4.0.0` or later in your `devDependencies`. [#4151](https://github.com/reactive/data-client/pull/4151)

### Upgrade support

As usual, if you have any troubles or questions, feel free to join our [![Chat](https://img.shields.io/discord/768254430381735967.svg?style=flat-square&colorB=758ED3)](https://discord.gg/wXGV27xm6t) or [file a bug](https://github.com/reactive/data-client/issues/new/choose)
Loading