v0.19: Batch Controller.set(), Faster TypeScript
v0.19 lets a Manager write a whole batch of streamed rows in one store update, type-checks endpoint code about 2x faster, and fixes a round of TypeScript and Vue issues.
New APIs:
- Controller.set() with Array schemas - Write many entities in one store update with
ctrl.set([Entity], rows), up to 95x faster than oneset()per row
Performance:
- Faster TypeScript - Editors and
tsccheck RestEndpoint and resource() code about 2x faster with 40% less memory, catching every error they caught before
Other Improvements:
- Fix TypeScript 7 module resolution for package
exports; imports now resolve to declaration files (#4019) - renderDataHook() runs provider mount effects when the first render suspends (#4099)
- Controller.set() values are typed by the schema, so
ctrl.set(new schema.All(Todo), 42)is a TypeScript error (#4133) - Vue useSuspense() and useLive() keep the previous data while new arguments load, instead of returning
undefined(#4131) - Vue useSuspense() and useLive() send fetch errors after arguments change to
onErrorCaptured()instead of an unhandled promise rejection (#4135) - Vue useSuspense(), useDLE() and useFetch() no longer refetch stale data on every store update, so a
controller.set()is not overwritten (#4134) - Vue useSuspense() shows stale data on mount and refetches in the background, like React, instead of showing the
<Suspense>fallback until the refetch finishes (#4169) - Vue useDLE() and useCache() keep expired
invalidIfStaledata through unrelated store updates instead of getting stuck loading (#4142) - Vue
DataClientPlugininstalls on Vue versions before 3.5 instead of throwingapp.onUnmount is not a function(#4146) - Vue composables accept getter arguments like
() => ({ id: props.id }); they were typed to allow them but passed the function itself to the endpoint (#4115) - Fix
Cannot find name 'NoInfer'andexport typeerrors on TypeScript 4.x withskipLibCheckoff (#4138) - Fix
Entity,Endpoint,UnionandRestEndpointtype errors on TypeScript 4.0–4.5 withskipLibCheckoff (#4140) - Entity classes can be used where an
EntityInterfaceis expected (#4149); preparepk()overrides for a future release - useCache() and useDLE() return
undefinedfor a deleted entity whose refetch failed, instead of a truthySymbolthat slipped pastif (!data)checks; remove any workarounds (#4150) - Vue useFetch() keeps its data from being garbage collected while mounted, like useSuspense(), so a configured
gcPolicyno longer evicts prefetched data that components read later (#4152) - Apps with Redux DevTools open no longer stutter in development on large stores or frequent updates; each update serializes 40-60x faster (#4163)
- resource().extend() with an object of endpoint overrides keeps endpoints added earlier with
.extend('name', options)in its type, so using them is no longer a TypeScript error (#4184) - Vue useFetch() is typed as the read-only
Refit returns, sopromise.resolved(alwaysundefined) is now a TypeScript error; readpromise.value.resolvedinstead (#4114)
- TypeScript 4.0 or later is required - the TypeScript 3.x declarations are removed (#4151)
Batch Controller.set()
A Manager that receives a stream of rows (like price tickers over a websocket) can now write
them all with one Controller.set() by passing an Array schema.
Before v0.19, [Ticker] was a TypeScript error, so the usual workaround was a set() per row:
ws.onmessage = event => {
const rows = JSON.parse(event.data);
for (const row of rows) {
ctrl.set(Ticker, { product_id: row.product_id }, row);
}
};
ws.onmessage = event => {
const rows = JSON.parse(event.data);
ctrl.set([Ticker], rows);
};
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,
Invalidate, or Values schema; see
Controller.set() for examples.
#4103
Try both buttons below. This browser check starts from an empty store and times Promise.all of 500 set()
calls against one batch set(). Both paths are one React commit, and each writes 500 new prices.
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 />);
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 that copy once.
The chart is the node setMany benchmark in
examples/benchmark/core.js:
a store that already holds 500 entities, then a synchronous set() per row against one set([Ticker], rows).
It measures the store update: 20x faster for 50 rows and 95x faster for 500 rows.
#4103
In a Manager, buffer incoming messages and flush each batch with one set(), as described in
Batching high-frequency updates. The coin app's StreamManager now
flushes Coinbase ticker messages this way.
handleMessage(msg: any) {
if (msg.type in this.entities) {
(this.buffer[msg.type] ??= {})[msg.product_id] = msg;
this.flushTimeout ??= setTimeout(this.flush, 50);
}
}
flush = () => {
const buffer = this.buffer;
this.buffer = {};
this.flushTimeout = undefined;
for (const type in buffer) {
this.controller.set([this.entities[type]], Object.values(buffer[type]));
}
};
Explore the coin-app example
If you filter DevToolsManager actions by schema, a batched write's action.schema
is the Array schema, so match action.schema[0] for [Ticker] rather than the Entity itself.
Faster TypeScript
TypeScript re-checks your code on every keystroke in the editor and on every CI build. In apps with many endpoints,
heavy library types show up as laggy autocomplete, red squiggles that take seconds to appear, and slower builds.
v0.19 makes RestEndpoint, resource() and .extend() much cheaper
to check, with no code changes on your side. TypeScript still reports every error it reported before
(#4173).
Results
Our heaviest stress test, a file of 150 RestEndpoints with long paths, .extend() and .paginated(), now checks
2x faster on TypeScript 6 and 2.2x faster on TypeScript 7, using about 40% less memory. All numbers compare
the published v0.18.1 packages with v0.19.
Endpoint-heavy code does much less type work. Type instantiations are TypeScript's unit of work: they're deterministic, so they compare cleanly across machines. Union does more work than in v0.18, because v0.19 now type-checks set() values (see below).
Hover or tap a cell for exact numbers.
| TS 6 check time | TS 7 check time | TS 6 memory | |
|---|---|---|---|
| Long paths 150 RestEndpoints with 6-param paths, .extend() and .paginated() | -51% 3.73s → 1.82s | -55% 1.61s → 0.73s | -41% 352MB → 207MB |
| Typical app A few resources with .extend(), .paginated() and hooks | same 0.42s → 0.42s | +8% 0.063s → 0.068s | +7% 101MB → 108MB |
| React hooks 40 resources through every hook, plus ctrl.fetch() and ctrl.set() | -1% 1.21s → 1.2s | -5% 0.44s → 0.42s | -2% 164MB → 160MB |
| Vue The same 40 resources through every composable | -3% 0.66s → 0.64s | -18% 0.17s → 0.14s | -5% 145MB → 138MB |
| 300 fields One Entity with 300 fields, read and updated 100 times | -7% 0.43s → 0.4s | -32% 0.085s → 0.058s | -5% 111MB → 105MB |
| Schemas All, Query, Invalidate, Array, Object and Collection | +4% 1.01s → 1.05s | -3% 0.34s → 0.33s | -3% 155MB → 151MB |
| Union A 30-member Union in a Collection and Values | +5% 0.4s → 0.42s | +8% 0.075s → 0.081s | -8% 106MB → 98MB |
| set() values 1000 ctrl.set() calls on a 30-member Union, a Collection of it and a 300-field Entity | -18% 1.48s → 1.22s | -46% 0.5s → 0.27s | -23% 175MB → 135MB |
| set() updaters 1000 ctrl.set(Union, args, prev => ...) updaters on a 30-member Union | +220% 3.2s → 10.25s | +124% 1.68s → 3.77s | +7% 378MB → 404MB |
The set() rows measure typed set() values, which v0.18 didn't check at all. Plain values still check faster than before. Updater functions on large Unions cost more, since TypeScript now checks each updater's return value; we're working on bringing that back down.
Small files are dominated by TypeScript's fixed startup cost (loading lib.dom.d.ts alone takes about 100MB), so
their time and memory barely move. The savings add up as a codebase grows.
What changed
.extend()and.paginated()are shared across all endpoints instead of re-created for each endpoint type.- Endpoint options infer as plain object types, so TypeScript stops rebuilding them at every use.
- Path parameters like
/users/:idare read in a single pass.
Before shipping, we turned off every @ts-expect-error in the test suite on TypeScript 4.0 through 7 and confirmed
every error still appears in the same place.
On TypeScript 5.x and earlier, a process(value, params) method passed to .extend() also no longer fails with
"implicitly has an 'any' type" under strict.
Other improvements
Typed set() values
Controller.set() previously accepted any value for a schema, so a typo or a wrong
field type only surfaced as bad data at runtime. Values are now typed by the schema: an
Entity takes its fields, while a Collection, All or
Array takes a list of rows. Every field is optional, since set() merges into what is already stored, and
numbers and strings are interchangeable just like in API responses.
#4133
Hover the red underlines to see each error.
import type { Controller } from '@data-client/react'; import { schema } from '@data-client/rest'; import { Todo, TodoResource } from './Todo'; export function updateTodos(ctrl: Controller) { // ✅ rows are partial Todos; ids may be strings or numbers ctrl.set(TodoResource.getList.schema, [{ id: '5', completed: true }]); ctrl.set(Todo, { id: 5 }, todo => ({ completed: !todo.completed })); ctrl.set([Todo], [{ id: 1, title: 'first' }, { id: 2 }]); // ❌ All takes a list of rows ctrl.set(new schema.All(Todo), 42); // ❌ completed is a boolean ctrl.set(Todo, { id: 5 }, { id: 5, completed: 'yes' }); // ❌ Todo has no done field ctrl.set(TodoResource.getList.schema, [{ id: 5, done: true }]); // ❌ updaters must return Todo fields ctrl.set(Todo, { id: 5 }, todo => ({ title: todo.completed })); }
A Query takes the input of the schema it wraps, since set() normalizes that schema rather than
reversing process(). To keep type checking fast for large Unions, a Union row is checked
against the combined fields of all its members, so a row mixing fields from different members is not an error; see
type checking limits.
If code that previously compiled now fails here, it was writing data its schema doesn't describe. Fix the value, or widen the Entity's field types if the data really can take that shape.
Vue getter arguments
Vue composables like useSuspense() and useLive() were typed to accept a
getter function as an argument, but they passed the function itself to the endpoint instead of calling it. Getters now
work the same as refs and computed, and the composable refetches when anything the getter reads changes
(#4115). Drop the computed() wrapper if you only used it to make
arguments reactive:
import { computed } from 'vue';
const props = defineProps<{ id: number }>();
const article = await useSuspense(
ArticleResource.get,
computed(() => ({ id: props.id })),
);
const props = defineProps<{ id: number }>();
const article = await useSuspense(ArticleResource.get, () => ({
id: props.id,
}));
This applies to every composable that takes endpoint arguments: useSuspense(), useLive(), useCache(), useDLE(), useFetch(), useQuery() and useSubscription().
Vue stale-while-revalidate
When a component mounts with data that is stale but still valid, Vue
useSuspense() now renders it right away and refetches in the background, like React. Before,
it showed the <Suspense> fallback until the refetch finished (#4169).
Entity classes typecheck as EntityInterface
Helpers typed to accept any Entity with EntityInterface rejected Entity classes, because
Entity.pk() typed its args as a mutable array. It is now readonly any[], so this
typechecks (#4149):
import { Entity } from '@data-client/rest'; import type { EntityInterface } from '@data-client/react'; class User extends Entity { id = ''; name = ''; } function entityName(schema: EntityInterface) { return schema.key; } entityName(User);
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:
class User extends Entity {
static pk(value: any, parent?: any, key?: string, args?: any[]) {
return `${value.id}-${args?.[0]?.org}`;
}
}
class User extends Entity {
static pk(value: any, parent?: any, key?: string, args?: readonly any[]) {
return `${value.id}-${args?.[0]?.org}`;
}
}
Deleted entities read as undefined
When an entity was deleted and its refetch failed, useCache() and useDLE()
(React and Vue) returned an internal Symbol as data. A Symbol is truthy, so the usual "not loaded yet" guard let it
through and the component rendered as if it had an entity
(#4150):
function TodoDetail({ id }: { id: number }) {
const todo = useCache(TodoResource.get, { id });
// a deleted todo used to get past this guard, so todo.title.trim() threw
if (!todo) return <TodoPlaceholder />;
return <h3>{todo.title.trim()}</h3>;
}
Now data is undefined, matching its type and Controller.get(). The same applies to
Controller.getResponse() and
Controller.fetchIfStale(), for example in custom Managers.
If you worked around this, the workaround can go. A plain truthiness check is enough:
function TodoDetail({ id }: { id: number }) {
const todo = useCache(TodoResource.get, { id });
if (!todo || typeof todo === 'symbol') return <TodoPlaceholder />;
return <h3>{todo.title.trim()}</h3>;
}
function TodoDetail({ id }: { id: number }) {
const todo = useCache(TodoResource.get, { id });
if (!todo) return <TodoPlaceholder />;
return <h3>{todo.title.trim()}</h3>;
}
To show something specific when the refetch failed (for example a 404 after deletion), read error from
useDLE() rather than inspecting data.
Faster Redux DevTools
With the Redux DevTools extension open, every store update in development
serializes the whole store for the extension, and each timestamp in it was formatted the slow way. Large stores or
frequent updates, like polling, live data or many controller.set() calls, made the page stutter. Each update now
serializes 40-60x faster, and timestamps still read like 10:42:07.123 AM
(#4163). Production builds don't include DevTools, so they are
unaffected.
Migration guide
This upgrade requires updating all package versions simultaneously.
- NPM
- Yarn
- pnpm
- esm.sh
yarn add @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
npm install --save @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
pnpm add @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
<script type="module">
import * from 'https://esm.sh/@data-client/react@^0.19.0';
import * from 'https://esm.sh/@data-client/rest@^0.19.0';
import * from 'https://esm.sh/@data-client/endpoint@^0.19.0';
import * from 'https://esm.sh/@data-client/core@^0.19.0';
import * from 'https://esm.sh/@data-client/vue@^0.19.0';
import * from 'https://esm.sh/@data-client/test@^0.19.0';
import * from 'https://esm.sh/@data-client/img@^0.19.0';
</script>
TypeScript 4.0 or later
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
Upgrade support
As usual, if you have any troubles or questions, feel free to join our or file a bug
