diff --git a/website/blog/2026-10-03-v0.19-batch-set.md b/website/blog/2026-10-03-v0.19-batch-set.md
index cf1b4c57df3d..5d1b722ef014 100644
--- a/website/blog/2026-10-03-v0.19-batch-set.md
+++ b/website/blog/2026-10-03-v0.19-batch-set.md
@@ -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:**
@@ -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);
-}
+
+
+```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.
+
+
+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 });
+
+
+```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) => Promise,
+ ) => {
+ 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 (
+
+ {' '}
+
+
COIN-0: {first ? `$${first.price}` : 'no data yet'}
+
{timing}
+
+ );
+}
+render();
+```
+
+
+
### 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)
@@ -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]
```
-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)
-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.
@@ -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)).
+
```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
+
-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
+
+
+```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}`;
}
}
```
-:::
+
### Deleted entities read as undefined {#deleted-entity-undefined}
@@ -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.
+
+
+
+### 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 [](https://discord.gg/wXGV27xm6t) or [file a bug](https://github.com/reactive/data-client/issues/new/choose)