Skip to main content

v0.19: Batch Controller.set(), Faster TypeScript

· 18 min read
Nathaniel Tucker
Creator of Reactive Data Client

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:

Performance:

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)
  • process() in .extend() gets typed params, so a wrong path parameter is a TypeScript error instead of undefined at runtime (#4183)
  • 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 invalidIfStale data through unrelated store updates instead of getting stuck loading (#4142)
  • Vue DataClientPlugin installs on Vue versions before 3.5 instead of throwing app.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' and export type errors on TypeScript 4.x with skipLibCheck off (#4138)
  • Fix Entity, Endpoint, Union and RestEndpoint type errors on TypeScript 4.0–4.5 with skipLibCheck off (#4140)
  • Entity classes can be used where an EntityInterface is expected (#4149); prepare pk() overrides for a future release
  • useCache() and useDLE() return undefined for a deleted entity whose refetch failed, instead of a truthy Symbol that slipped past if (!data) checks; remove any workarounds (#4150)
  • Vue useFetch() keeps its data from being garbage collected while mounted, like useSuspense(), so a configured gcPolicy no 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)
  • Vue 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)

Breaking Changes:

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:

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
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 />);
🔴 Live Preview
Store▶

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

Node setMany into a 500-entity store
50 rows20xOne set() per row 10.8 ms → set([Ticker], rows) 0.54 ms
500 rows95xOne set() per row 103 ms → set([Ticker], rows) 1.08 ms

Benchmarks over time | View setMany benchmark

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.

StreamManager.ts
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

More Demos

tip

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.

Checking 150 RestEndpoints with long paths
TypeScript 62xv0.18 3.73 s → v0.19 1.82 s
TypeScript 72.2xv0.18 1.61 s → v0.19 0.73 s
Memory for the same file
TypeScript 61.7xv0.18 352 MB → v0.19 207 MB
TypeScript 71.7xv0.18 220 MB → v0.19 127 MB

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).

Type instantiations by stress test
Long paths2.4xv0.18 822 K → v0.19 341 K
Typical app1.6xv0.18 20.2 K → v0.19 13 K
React hooks1.1xv0.18 145 K → v0.19 134 K
Vue1.3xv0.18 62.6 K → v0.19 47.3 K
300 fields1xv0.18 17 K → v0.19 17.2 K
Schemas1.2xv0.18 118 K → v0.19 101 K
Union0.7xv0.18 9 K → v0.19 13.6 K

Hover or tap a cell for exact numbers.

TS 6 check timeTS 7 check timeTS 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/:id are 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.

Typed process() params in extend()​

A process() method passed to .extend() got params typed as any, so reading a parameter the endpoint doesn't have compiled and returned undefined at runtime. params and body are now typed from the extended endpoint, including a path set in the same call (#4183):

import { RestEndpoint } from '@data-client/rest';

const getUser = new RestEndpoint({ path: '/users/:id' });

export const getUserById = getUser.extend({
  path: '/users/by-id/:userId',
  process(value, params) {
    const userId: string | number = params.userId;
    // @ts-expect-error 'id' is not a param of '/users/by-id/:userId'
    params.id;
    return { ...value, userId };
  },
});

When every param is optional, params may be undefined, so read it with params?.page.

This can surface new TypeScript errors in existing process() methods. Each one marks a read that could be undefined or throw at runtime, so fix the param name or add the missing check.

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:

Before
import { computed } from 'vue';

const props = defineProps<{ id: number }>();
const article = await useSuspense(
ArticleResource.get,
computed(() => ({ id: props.id })),
);
After
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:

Before
class User extends Entity {
static pk(value: any, parent?: any, key?: string, args?: any[]) {
return `${value.id}-${args?.[0]?.org}`;
}
}
After
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:

Before
function TodoDetail({ id }: { id: number }) {
const todo = useCache(TodoResource.get, { id });
if (!todo || typeof todo === 'symbol') return <TodoPlaceholder />;
return <h3>{todo.title.trim()}</h3>;
}
After
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.

DevTools serialization per store update
50 entities60xv0.18 18.6 ms → v0.19 0.31 ms
500 entities40xv0.18 201 ms → v0.19 5 ms

Migration guide​

This upgrade requires updating all package versions simultaneously.

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

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 Chat or file a bug