Skip to main content

Scalar

Scalar handles entity fields whose values depend on a "lens" selection, such as viewing portfolio-specific data for companies. Multiple components can render the same entity through different lenses simultaneously, each seeing the correct values.

Data is stored in an internal Scalar entity table and joined at denormalize time based on endpoint args. The parent entity stores lens-independent references, so switching lenses never mutates the entity itself.

Usage

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

const PortfolioScalar = new Scalar({
lens: args => args[0]?.portfolio,
key: 'portfolio',
});

class Company extends Entity {
id = '';
price = 0;
pct_equity = 0;
shares = 0;

static schema = {
pct_equity: PortfolioScalar,
shares: PortfolioScalar,
};
}

A single Scalar instance can be shared across multiple Entity classes — the entity context is inferred at normalize time from the parent entity and recorded on the wrapper, so denormalize always resolves to the correct cell. Compound pks remain unique because they are namespaced by entity key (e.g. Company|1|portfolioA vs. Fund|1|portfolioA).

Full entity endpoint

When fetching companies with a portfolio lens, scalar fields are automatically extracted and stored in the Scalar cell table:

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

const getCompanies = new Endpoint(
({ portfolio }: { portfolio: string }) =>
fetch(`/companies?portfolio=${portfolio}`).then(r => r.json()),
{ schema: [Company] },
);

Column-only endpoint

Fetch just the lens-dependent columns without refetching entity data. The response is a dictionary keyed by entity pk. Because there is no parent entity in this path, the Scalar must be bound to an Entity class via the entity option:

const CompanyPortfolioScalar = new Scalar({
lens: args => args[0]?.portfolio,
key: 'portfolio',
entity: Company,
});

const getPortfolioColumns = new Endpoint(
({ portfolio }: { portfolio: string }) =>
fetch(`/companies/columns?portfolio=${portfolio}`).then(r => r.json()),
{ schema: new schema.Values(CompanyPortfolioScalar) },
);
// Response: { '1': { pct_equity: 0.5, shares: 32342 }, '2': { ... } }

Column fetches only write Scalar(portfolio) cell entries — they never modify the Company entities.

A bound Scalar can also be used as an Entity.schema field (the binding is just ignored in favor of the inferred parent), so a single instance can serve both endpoints.

Combined usage: portfolio grid

Both endpoints share a single Scalar instance and feed the same grid. getCompanies loads the full row data on first render; getPortfolioColumns fires alongside as a cheaper lens-only refresh — both write to the same Scalar(portfolio) cell table. Switch portfolios in the dropdown to watch % Equity and Shares swap while the Company rows themselves stay stable.

Fixtures
GET /companies?portfolio=A
[{"id":"1","name":"Acme Corp","price":145.2,"pct_equity":0.5,"shares":10000},{"id":"2","name":"Globex","price":89.5,"pct_equity":0.2,"shares":4000},{"id":"3","name":"Initech","price":32.1,"pct_equity":0.1,"shares":2500}]
GET /companies?portfolio=B
[{"id":"1","name":"Acme Corp","price":145.2,"pct_equity":0.3,"shares":6000},{"id":"2","name":"Globex","price":89.5,"pct_equity":0.4,"shares":8000},{"id":"3","name":"Initech","price":32.1,"pct_equity":0.05,"shares":1200}]
GET /companies/columns?portfolio=A
{"1":{"pct_equity":0.5,"shares":10000},"2":{"pct_equity":0.2,"shares":4000},"3":{"pct_equity":0.1,"shares":2500}}
GET /companies/columns?portfolio=B
{"1":{"pct_equity":0.3,"shares":6000},"2":{"pct_equity":0.4,"shares":8000},"3":{"pct_equity":0.05,"shares":1200}}
api/Company
import { Entity, RestEndpoint, Scalar, schema } from '@data-client/rest';

export class Company extends Entity {
  id = '';
  name = '';
  price = 0;
  pct_equity = 0;
  shares = 0;
}

// Bound to Company so both endpoints below can use this same instance.
const PortfolioScalar = new Scalar({
  lens: args => args[0]?.portfolio,
  key: 'portfolio',
  entity: Company,
});
Company.schema = {
  pct_equity: PortfolioScalar,
  shares: PortfolioScalar,
};

export const getCompanies = new RestEndpoint({
  path: '/companies',
  searchParams: {} as { portfolio: string },
  schema: [Company],
});

export const getPortfolioColumns = new RestEndpoint({
  path: '/companies/columns',
  searchParams: {} as { portfolio: string },
  schema: new schema.Values(PortfolioScalar),
});
PortfolioGrid
import { useSuspense, useFetch } from '@data-client/react';
import { getCompanies, getPortfolioColumns } from './api/Company';

function PortfolioGrid() {
  const [portfolio, setPortfolio] = React.useState('A');
  // Full load: Company rows + Scalar cells for the current lens.
  const companies = useSuspense(getCompanies, { portfolio });
  // Cheap lens-only refresh in the background — writes to the same cell table.
  useFetch(getPortfolioColumns, { 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>
      <table style={{ marginTop: 8, width: '100%' }}>
        <thead>
          <tr>
            <th align="left">Name</th>
            <th align="right">Price</th>
            <th align="right">% Equity</th>
            <th align="right">Shares</th>
          </tr>
        </thead>
        <tbody>
          {companies.map(c => (
            <tr key={c.pk()}>
              <td>{c.name}</td>
              <td align="right">${c.price.toFixed(2)}</td>
              <td align="right">{(c.pct_equity * 100).toFixed(1)}%</td>
              <td align="right">{c.shares.toLocaleString()}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
render(<PortfolioGrid />);
🔴 Live Preview
Store

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. In a real app, you'd typically reach for getPortfolioColumns instead of getCompanies after the initial load to avoid refetching lens-independent fields.

Constructor

new Scalar({ lens, key, entity? })

Options

  • lens (args: readonly any[]) => string | undefinedrequired. Extracts the lens value (e.g. portfolio ID) from endpoint args.
  • key stringrequired. A unique name for this scalar type, used to namespace the internal Scalar entity table (e.g. 'portfolio' becomes Scalar(portfolio)).
  • entity Entity classoptional. The Entity class this Scalar attaches to. Only required when the Scalar is used standalone (e.g. inside schema.Values for a column-only endpoint), where there is no parent entity to infer from. When used as a field on Entity.schema, the entity is inferred from the parent and recorded on the wrapper.

How it works

Normalize

EntityMixin.normalize's field loop is a single uniform visit(schema, value, parent, key, args) call per field — there is no Scalar-specific branch. When the field's schema is a Scalar:

  1. The visit walker (getVisit) tracks the nearest enclosing entity-like schema in a closure and passes it to schema.normalize as a 7th parentEntity argument. Scalar opts out of the primitive short-circuit via acceptsPrimitives = true, so its normalize receives the raw field value (e.g. 0.5).
  2. Scalar.normalize reads parentEntity.key / parentEntity.pk(parent, …, args) to build the compound cell pk (entityKey|entityPk|lensValue) and writes just that one field via delegate.mergeEntity(this, compoundPk, { [key]: input }). The merge lifecycles accumulate the per-call writes into a full cell as the loop runs over each scalar field.
  3. A lens-independent tuple [entityPk, fieldName, entityKey] replaces the scalar field on the entity row (an array, distinguishable from cell data via Array.isArray).

Denormalize

The standard EntityMixin.denormalize loop is completely unchanged:

  1. delegate.unvisit(Scalar, wrapper) calls Scalar.denormalize (Scalar is not entity-like).
  2. Scalar.denormalize reads the wrapper tuple and calls delegate.argsKey(this.lensSelector) — this both extracts the current lens from endpoint args and registers that lens as a memoization dimension on the surrounding entity-cache frame, so the cached result varies per-lens. The lens selector is bound on the Scalar instance so its reference stays stable across calls (required for WeakDependencyMap to hit).
  3. It builds the compound cell pk, calls delegate.unvisit(this, compoundPk) to look up the cell, then returns the specific field value.

This means different components viewing the same entity with different lens args get different scalar values, while sharing the same base entity data — and the memo cache keeps per-lens results independently without any cross-bucket invalidation.

Normalized storage

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

entities['Scalar(portfolio)']['Company|1|portfolioA'] = {
pct_equity: 0.5,
shares: 32342,
}

entities['Scalar(portfolio)']['Company|1|portfolioB'] = {
pct_equity: 0.3,
shares: 323,
}
  • Entity — defines the base entity that scalar fields attach to
  • Values — used for column-only endpoints (dictionary keyed by entity pk)
  • Union — similar wrapper pattern for polymorphic entities