Skip to main content

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

· 19 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:

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; check times are the best of 10 runs.

Checking 150 RestEndpoints with long paths
TypeScript 61.9xv0.18 3.83 s → v0.19 1.98 s
TypeScript 72.2xv0.18 1.5 s → v0.19 0.69 s
Memory for the same file
TypeScript 61.7xv0.18 365 MB → v0.19 211 MB
TypeScript 71.7xv0.18 220 MB → v0.19 128 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 346 K
Typical app1.6xv0.18 20.2 K → v0.19 13 K
React hooks1.2xv0.18 145 K → v0.19 122 K
Vue1.3xv0.18 62.6 K → v0.19 47.3 K
300 fields1.3xv0.18 17 K → v0.19 12.8 K
Schemas1.2xv0.18 118 K → v0.19 99.3 K
Union0.6xv0.18 9 K → v0.19 16.1 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()
-48%
3.83s → 1.98s
-54%
1.5s → 0.69s
-42%
365MB → 211MB
Typical app
A few resources with .extend(), .paginated() and hooks
same
0.42s → 0.42s
-15%
0.062s → 0.053s
+3%
106MB → 109MB
React hooks
40 resources through every hook, plus ctrl.fetch() and ctrl.set()
-10%
1.24s → 1.12s
-7%
0.41s → 0.38s
-6%
167MB → 157MB
Vue
The same 40 resources through every composable
same
0.68s → 0.68s
-12%
0.17s → 0.15s
-4%
145MB → 139MB
300 fields
One Entity with 300 fields, read and updated 100 times
-14%
0.44s → 0.38s
-40%
0.086s → 0.052s
-9%
111MB → 101MB
Schemas
All, Query, Invalidate, Array, Object and Collection
-3%
1.1s → 1.07s
-14%
0.36s → 0.31s
-4%
156MB → 150MB
Union
A 30-member Union in a Collection and Values
-2%
0.42s → 0.41s
-4%
0.071s → 0.068s
-6%
107MB → 101MB
set() values
1000 ctrl.set() calls on a 30-member Union, a Collection of it and a 300-field Entity
-28%
1.49s → 1.07s
-54%
0.54s → 0.25s
-26%
176MB → 131MB
set() updaters
1000 ctrl.set(Union, args, prev => ...) updaters on a 30-member Union
-4%
3.58s → 3.43s
+11%
1.67s → 1.85s
+1%
381MB → 384MB

The set() rows measure typed set() values, which v0.18 didn't check at all. Plain values still check faster than before, and updater functions on large Unions check in about the same time even though TypeScript now checks each updater's return value (#4179).

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(). When each member declares its discriminator as a literal (like readonly type = 'post'), a Union row is checked against the member it selects, so it can't mix fields from different members; see Unions.

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 garbage collects by default​

Vue's DataClientPlugin only removed unused data if you passed a gcPolicy yourself. It now defaults to new GCPolicy(), the same as React's DataProvider, so you can drop the option unless you customize it (#4148).

Before
import { DataClientPlugin, GCPolicy } from '@data-client/vue';

app.use(DataClientPlugin, { gcPolicy: new GCPolicy() });
After
import { DataClientPlugin } from '@data-client/vue';

app.use(DataClientPlugin);

Data is removed only when no mounted component reads it and twice its freshness lifetime has passed since it was fetched (at least two minutes). A sweep checks every five minutes. Anything a component reads again before then is back in use.

A component that mounts after removal treats the data as never fetched: useSuspense() fetches it again, while useCache() returns undefined. To keep unused data longer or sweep less often, pass your own gcPolicy.

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