Skip to main content

v0.18: Scalar, Typed File Downloads, Filter-aware Collections

ยท 11 min read
Nathaniel Tucker
Creator of Reactive Data Client

v0.18 focuses on richer data modeling for values that vary by request context, simpler typed downloads, and collection matching that better reflects real API filters.

New Features:

Other Improvements:

import { useSuspense, useFetch } from '@data-client/react';
import { getCompanies, getPortfolioColumns } from './api/Company';
import CompanyGrid from './CompanyGrid';

function PortfolioGrid() {
  const [portfolio, setPortfolio] = React.useState('A');
  // Fetches on first render, then re-denormalizes from cache on every
  // portfolio switch. The Collection's `queryKey()` ignores `portfolio`,
  // so there is no endpoint refetch on switch.
  const companies = useSuspense(getCompanies, { portfolio });
  // The first render's `useSuspense` already populated `Scalar(portfolio)`
  // for `firstPortfolio`, so we only fetch columns when the user switches
  // away. `useFetch` then dedupes later revisits via its endpoint cache.
  const firstPortfolio = React.useRef(portfolio).current;
  useFetch(
    getPortfolioColumns,
    portfolio === firstPortfolio ? null : { portfolio },
  );

  return (
    <div>
      <label>
        Portfolio:{' '}
        <select
          value={portfolio}
          onChange={e => setPortfolio(e.currentTarget.value)}
        >
          <option value="A">Portfolio A</option>
          <option value="B">Portfolio B</option>
        </select>
      </label>
      <CompanyGrid companies={companies} />
    </div>
  );
}

render(<PortfolioGrid />);
๐Ÿ”ด Live Preview
Storeโ–ถ

The example shows the same Company entities rendered through different portfolio lenses. Stable Entity fields are reused, while lens-dependent values are selected from separate Scalar cells.

Breaking Changes:

Upgrade with the automated codemod:

npx jscodeshift -t https://dataclient.io/codemods/v0.18.js --extensions=ts,tsx,js,jsx src/

Scalarโ€‹

Scalar handles Entity fields whose values depend on a runtime "lens" โ€” like the selected portfolio, currency, or locale. Multiple components can render the same entity through different lenses simultaneously, each seeing the correct values, while the entity itself never changes. Lens-dependent values live in a separate cell table and are joined at denormalize time from endpoint args.

name and price references stay stable across portfolio switches because the Company entity itself never changes โ€” only the Scalar cell selected by the current lens does. A single Scalar instance can serve both as an Entity.schema field (parent entity inferred from the visit) and standalone inside Values, [Scalar], or Collection([Scalar]) for cheap column-only refreshes (entity bound explicitly via entity). Cell pks are derived from the map key or via Scalar.entityPk(), which defaults to Entity.pk() so custom and composite primary keys work without an override.

The normalized store keeps the entity stable and the lens cells separate:

entities['Company']['1'] = {
id: '1',
price: 145.20,
pct_equity: ['1', 'pct_equity', 'Company'],
shares: ['1', 'shares', 'Company'],
}

entities['Scalar(portfolio)']['Company|1|A'] = { pct_equity: 0.50, shares: 10000 }
entities['Scalar(portfolio)']['Company|1|B'] = { pct_equity: 0.30, shares: 6000 }

The demo pairs getCompanies with a cheap getPortfolioColumns lens-only refresh writing to the same cell table โ€” see the Scalar documentation for a full walkthrough of the caching behavior.

Scalar also implements queryKey so it participates directly in useQuery, Controller.get, and schema.Query โ€” the full Queryable surface โ€” enumerating its cells for the current lens.

#3887 - Scalar docs

RestEndpoint content propertyโ€‹

RestEndpoint now accepts a content property that controls how the response body is parsed. The return type is inferred automatically from the value, and schema is constrained to undefined at the type level for non-JSON content types.

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

const downloadFile = new RestEndpoint({
path: '/files/:id/download',
content: 'blob',
dataExpiryLength: 0,
});

// Return type is Blob โ€” inferred from content, no explicit generics needed
const blob: Blob = await ctrl.fetch(downloadFile, { id: '123' });

Previously, file downloads required a verbose parseResponse override. Now it's a single property.

ValueParses viaReturn type
'json'response.json()any
'blob'response.blob()Blob
'text'response.text()string
'arrayBuffer'response.arrayBuffer()ArrayBuffer
'stream'response.bodyReadableStream<Uint8Array>

Setting content to a non-JSON value enforces schema: undefined at the type level, since binary data cannot be normalized. A runtime check provides a clear error message if a normalizable schema is accidentally used.

Works with extend() and subclasses:

// Extend a JSON endpoint into a blob downloader
const download = jsonEndpoint.extend({
content: 'blob',
schema: undefined,
});

// Subclass for a reusable blob endpoint type
class BlobEndpoint<O extends RestGenerics = any> extends RestEndpoint<O> {
content = 'blob' as const;
}

#3868 - content docs

resource() nonFilterArgumentKeysโ€‹

nonFilterArgumentKeys lets resource() declare which argument keys are not used to filter the collection results โ€” typically sort, pagination, or display options. Without it, every distinct sort or page args would create a separate Collection bucket, and newly created items wouldn't appear in lists with different sort args even though the underlying data should match.

import { Entity, resource } from '@data-client/rest';

class Post extends Entity {
id = '';
title = '';
group = '';
author = '';
}

const PostResource = resource({
path: '/:group/posts/:id',
searchParams: {} as { orderBy?: string; author?: string },
schema: Post,
nonFilterArgumentKeys: ['orderBy'],
});

Now group and author are filter keys (they bucket separate Collections), but orderBy is shared โ€” PostResource.getList.push() adds the new post to all orderBy variants of a given { group, author } bucket.

Accepts a string[], RegExp, or predicate function:

nonFilterArgumentKeys: /^(orderBy|page|cursor)$/,

#3914 - nonFilterArgumentKeys docs

Binary Content-Type auto-detectionโ€‹

When content is not set, parseResponse now auto-detects binary Content-Types and returns response.blob() instead of corrupting the data via .text().

Binary types like image/*, audio/*, video/*, font/*, application/octet-stream, application/pdf, and others are now handled automatically. Text-like types (text/*, XML, HTML, JavaScript, CSS, etc.) continue to use the existing text fallback with schema-aware error messages.

This means endpoints that serve binary data with correct Content-Type headers work without any configuration:

const ep = new RestEndpoint({
path: '/avatars/:id',
});
// Server responds with Content-Type: image/png
// โ†’ automatically returns a Blob (previously corrupted via .text())

#3868

Migration guideโ€‹

This upgrade requires updating all package versions simultaneously.

npm install --save @data-client/react@^0.18.0 @data-client/rest@^0.18.0 @data-client/endpoint@^0.18.0 @data-client/core@^0.18.0 @data-client/vue@^0.18.0 @data-client/test@^0.18.0 @data-client/img@^0.18.0

The breaking changes in this release affect only custom Schema implementations. If you only use resource() and built-in schemas (Entity, Collection, Union, Values, Array, Object, Query, Invalidate, Lazy, Scalar), the upgrade is drop-in. Otherwise, run the codemod to migrate custom schemas, then read on for the manual cases:

npx jscodeshift -t https://dataclient.io/codemods/v0.18.js --extensions=ts,tsx,js,jsx src/

Schema.denormalize() takes a delegateโ€‹

Skip this section if you don't implement custom Schema classes.

Schema.denormalize() is now (input, delegate) instead of the previous 3-parameter (input, args, unvisit) signature. The new IDenormalizeDelegate exposes unvisit, args, and a new argsKey(fn) helper. #3887

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

class Wrapper {
denormalize(
input: {},
args: readonly any[],
unvisit: (s: any, v: any) => any,
) {
const value = unvisit(this.schema, input);
return this.process(value, ...args);
}
}
After
import { Entity, type IDenormalizeDelegate } from '@data-client/rest';

class Wrapper {
denormalize(input: {}, delegate: IDenormalizeDelegate) {
const value = delegate.unvisit(this.schema, input);
return this.process(value, ...delegate.args);
}
}

The codemod handles class methods, object methods, function declarations, TypeScript interface signatures, and pass-through someSchema.denormalize(input, args, unvisit) calls. It also adds the IDenormalizeDelegate import as an inline type specifier on your existing @data-client/* import (or creates a new import type line if none is found).

args-dependent outputโ€‹

Reading delegate.args directly does not contribute to memoization. If your schema's output depends on those args (e.g. a lens), declare the dependency through delegate.argsKey(fn) so the cache buckets correctly. The codemod cannot infer this โ€” update by hand.

argsKey returns fn(args) for convenience, and the function reference doubles as the cache path key โ€” so the function must be referentially stable. Store it on the instance (constructor) or at module scope; never pass an inline arrow, or every call allocates a fresh reference and misses the cache.

Before
class LensSchema {
denormalize(input, args, unvisit) {
const lens = args[0]?.portfolio;
return this.lookup(input, lens);
}
}
After
class LensSchema {
constructor({ lens }) {
this.lensSelector = lens; // stable: set once in the constructor
}
denormalize(input, delegate) {
const lens = delegate.argsKey(this.lensSelector);
return this.lookup(input, lens);
}
}

See Scalar for a worked example.

AI-assisted migration is also available:

npx skills add reactive/data-client --skill data-client-v0.18-migration

Schema.normalize() takes a delegateโ€‹

Skip this section if you don't implement custom Schema classes.

Schema.normalize() now reads endpoint args and the recursive visitor from INormalizeDelegate, matching the new denormalize delegate shape. The previous signature was (input, parent, key, args, visit, delegate, parentEntity?); the new signature is (input, parent, key, delegate, parentEntity?). #3934

Before
import type { INormalizeDelegate, Visit } from '@data-client/rest';

class Wrapper {
normalize(
input: any,
parent: any,
key: string,
args: readonly any[],
visit: Visit,
delegate: INormalizeDelegate,
) {
const normalized = visit(this.schema, input.value, input, 'value', args);
delegate.mergeEntity(this, this.pk(input, parent, key, args), normalized);
return normalized;
}
}
After
import type { INormalizeDelegate } from '@data-client/rest';

class Wrapper {
normalize(
input: any,
parent: any,
key: string,
delegate: INormalizeDelegate,
) {
const { args, visit } = delegate;
const normalized = visit(this.schema, input.value, input, 'value');
delegate.mergeEntity(this, this.pk(input, parent, key, args), normalized);
return normalized;
}
}

If your schema accepts the optional parentEntity argument, keep it as the trailing argument:

normalize(input, parent, key, delegate, parentEntity) {
// ...
}

delegate.visit() already carries the same args for recursive normalization, so nested schemas do not need args passed explicitly.

Additive: parentEntity on normalizeโ€‹

The normalize delegate's visit() callback internally tracks the nearest enclosing entity-like schema and forwards it to Schema.normalize() as the optional trailing parentEntity argument. New schemas can opt in to discover their containing entity at normalize time (used internally by Scalar).

Optional: consolidate Collection definitionsโ€‹

Collection can now use both argsKey and nestKey on the same instance. As an optional cleanup, replace paired top-level and nested Collections with one shared definition:

Before
export const getTodos = new RestEndpoint({
path: '/todos',
searchParams: {} as { userId?: string },
schema: new Collection([Todo]),
});

class User extends Entity {
static schema = {
todos: new Collection([Todo], {
nestKey: parent => ({ userId: parent.id }),
}),
};
}
After
export const userTodos = new Collection([Todo], {
//argsKey: params => ({ ...params }), - this is default, so it's not needed
nestKey: parent => ({ userId: parent.id }),
});

export const getTodos = new RestEndpoint({
path: '/todos',
searchParams: {} as { userId?: string },
schema: userTodos,
});

class User extends Entity {
static schema = {
todos: userTodos,
};
}

Upgrade supportโ€‹

As usual, if you have any troubles or questions, feel free to join our Chat or file a bug