Skip to main content

EntityMixin

Entity defines a single unique object.

If you already have classes for your data-types, EntityMixin may be for you.

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

export class Article {
  id = '';
  title = '';
  content = '';
  tags: string[] = [];
}

export class ArticleEntity extends EntityMixin(Article) {}

Options

The second argument to the mixin can be used to conveniently customize construction. If not specified the Base class' static members will be used. Alternatively, just like with Entity, you can always specify these as static members of the final class.

class User {
  username = '';
  createdAt = Temporal.Instant.fromEpochMilliseconds(0);
}
class UserEntity extends EntityMixin(User, {
  pk: 'username',
  key: 'User',
  schema: { createdAt: Temporal.Instant.from },
}) {}

pk: string | (value, parent?, key?, args?) => string | number | undefined = 'id'

Specifies the Entity.pk

A string indicates the field to use for pk.

A function is used just like Entity.pk, but the first argument (value) is this

Defaults to 'id'; which means pk is a required option unless the Base class has a serializable id member.

multi-column primary key
class Thread {
  forum = '';
  slug = '';
  content = '';
}
class ThreadEntity extends EntityMixin(Thread, {
  pk(value) {
    return [value.forum, value.slug].join(',');
  },
}) {}

key: string

Specifies the Entity.key

schema: {[k:string]: Schema}

Specifies the Entity.schema

const vs class

If you don't need to further customize the entity, you can use a const declaration instead of extend to another class.

There is a subtle difference when referring to the class token in TypeScript - as class declarations will refer to the instance type; whereas const tokens refer to the value, so you must use typeof, but additionally typeof gives the class type, so you must layer InstanceType on top.

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

export class Article {
  id = '';
  title = '';
  content = '';
  tags: string[] = [];
}

export class ArticleEntity extends EntityMixin(Article) {}
export const ArticleEntity2 = EntityMixin(Article);

const article: ArticleEntity = ArticleEntity.fromJS();
const articleFails: ArticleEntity2 = ArticleEntity2.fromJS();
const articleWorks: InstanceType<typeof ArticleEntity2> =
  ArticleEntity2.fromJS();

Lifecycle

To override lifecycle methods like process(), you must use the class ... extends EntityMixin(...) {} form. The EntityMixin() options only include pk, key, and schema—lifecycle overrides live on the class itself.

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

export class Article {
  id = '';
  title = '';
  content = '';
  tags: string[] = [];
}

// ❌ Not supported (lifecycle methods are not EntityMixin options)
// export const ArticleEntity = EntityMixin(Article, {
//   process(input) {
//     return input;
//   },
// });

// ✅ Use a class when adding lifecycle methods
export class ArticleEntity extends EntityMixin(Article) {
  static process(input: any, parent: any, key: string | undefined, args: any[]) {
    const processed = super.process(input, parent, key, args);
    processed.tags ??= [];
    return processed;
  }
}

static fromJS(props): Entity

Factory method that copies props to a new instance. Use this instead of new MyEntity(), to ensure default props are overridden.

static process(input, parent, key, args): processedEntity

Run at the start of normalization for this entity. Return value is saved in store and sent to pk().

Defaults to simply copying the response ({...input})

How to override to build reverse-lookups for relational data

Case of the missing id

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

class Stream {
username = '';
title = '';
game = '';
currentViewers = 0;
live = false;
}

class StreamEntity extends EntityMixin(Stream) {
static key = 'Stream';

static process(value, parent, key, args) {
// super.process creates a copy of value
const processed = super.process(value, parent, key, args);
processed.username = args[0]?.username;
return processed;
}
}

Dynamic Invalidation

Returning undefined from Entity.process will cause the Entity to be invalidated. This this allows us to invalidate dynamically; based on the particular response data.

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

class PriceLevel {
price = 0;
amount = 0;
}

class PriceLevelEntity extends EntityMixin(PriceLevel) {
static process(
input: [number, number],
parent: any,
key: string | undefined,
): any {
const [price, amount] = input;
if (amount === 0) return undefined;
return { price, amount };
}
}

static mergeWithStore(existingMeta, incomingMeta, existing, incoming): mergedValue

static mergeWithStore(
existingMeta: {
date: number;
fetchedAt: number;
},
incomingMeta: { date: number; fetchedAt: number },
existing: any,
incoming: any,
) {
const shouldUpdate = this.shouldUpdate(
existingMeta,
incomingMeta,
existing,
incoming,
);

if (shouldUpdate) {
// distinct types are not mergeable (like delete symbol), so just replace
if (typeof incoming !== typeof existing) {
return incoming;
} else {
return this.shouldReorder(
existingMeta,
incomingMeta,
existing,
incoming,
)
? this.merge(incoming, existing)
: this.merge(existing, incoming);
}
} else {
return existing;
}
}

mergeWithStore() is called during normalization when a processed entity is already found in the store.

This calls shouldUpdate(), shouldReorder() and potentially merge()

static shouldUpdate(existingMeta, incomingMeta, existing, incoming): boolean

static shouldUpdate(
existingMeta: { date: number; fetchedAt: number },
incomingMeta: { date: number; fetchedAt: number },
existing: any,
incoming: any,
) {
return existingMeta.fetchedAt <= incomingMeta.fetchedAt;
}

Preventing updates

shouldUpdate can also be used to short-circuit an entity update.

import deepEqual from 'deep-equal';
import { EntityMixin } from '@data-client/rest';

class Article {
id = '';
title = '';
content = '';
published = false;
}

class ArticleEntity extends EntityMixin(Article) {
static shouldUpdate(
existingMeta: { date: number; fetchedAt: number },
incomingMeta: { date: number; fetchedAt: number },
existing: any,
incoming: any,
) {
return !deepEqual(incoming, existing);
}
}

static shouldReorder(existingMeta, incomingMeta, existing, incoming): boolean

static shouldReorder(
existingMeta: { date: number; fetchedAt: number },
incomingMeta: { date: number; fetchedAt: number },
existing: any,
incoming: any,
) {
return incomingMeta.fetchedAt < existingMeta.fetchedAt;
}

true return value will reorder incoming vs in-store entity argument order in merge. With the default merge, this will cause the fields of existing entities to override those of incoming, rather than the other way around.

Example

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

class LatestPrice {
  id = '';
  updatedAt = 0;
  price = '0.0';
  symbol = '';
}

class LatestPriceEntity extends EntityMixin(LatestPrice) {
  static shouldReorder(
    existingMeta: { date: number; fetchedAt: number },
    incomingMeta: { date: number; fetchedAt: number },
    existing: { updatedAt: number },
    incoming: { updatedAt: number },
  ) {
    return incoming.updatedAt < existing.updatedAt;
  }
}

More Demos

static merge(existing, incoming): mergedValue

static merge(existing: any, incoming: any) {
return {
...existing,
...incoming,
};
}

Merge is used to handle cases when an incoming entity is already found. This is called directly when the same entity is found in one response. By default it is also called when mergeWithStore() determines the incoming entity should be merged with an entity already persisted in the Reactive Data Client store.

How to override to build reverse-lookups for relational data

static mergeMetaWithStore(existingMeta, incomingMeta, existing, incoming): meta

static mergeMetaWithStore(
existingMeta: {
expiresAt: number;
date: number;
fetchedAt: number;
},
incomingMeta: { expiresAt: number; date: number; fetchedAt: number },
existing: any,
incoming: any,
) {
return this.shouldReorder(existingMeta, incomingMeta, existing, incoming)
? existingMeta
: incomingMeta;
}

mergeMetaWithStore() is called during normalization when a processed entity is already found in the store.

static queryKey(args, queryKey, getEntity, getIndex): pk?

This method enables Entities to be Queryable - allowing store access without an endpoint.

Overriding can allow customization or disabling of this behavior altogether.

Returning undefined will disallow this behavior.

Returning pk string will attempt to lookup this entity and use in the response.

When used, expiry policy is computed based on the entity's own meta data.

By default uses the first argument to lookup in pk() and indexes

getEntity(key, pk?)

Gets all entities of a type with one argument, or a single entity with two

One argument
const entitiesEntry = getEntity(this.schema.key);
if (entitiesEntry === undefined) return INVALID;
return Object.values(entitiesEntry).map(
entity => entity && this.schema.pk(entity),
);
Two arguments
if (getEntity(this.key, id)) return id;

getIndex(key, indexName, value)

Returns the index entry (value->pk map)

const value = args[0][indexName];
return getIndex(schema.key, indexName, value)[value];

static createIfValid(processedEntity): Entity | undefined

Called when denormalizing an entity. This will create an instance of this class if it is deemed 'valid'.

undefined return will result in Invalid expiry status, like Invalidate.

Invalid expiry generally means hooks will enter a loading state and attempt a new fetch.

static createIfValid(props): AbstractInstanceType<this> | undefined {
if (this.validate(props)) {
return undefined as any;
}
return this.fromJS(props);
}

static validate(processedEntity): errorMessage?

Runs during both normalize and denormalize. Returning a string indicates an error (the string is the message).

During normalization a validation failure will result in an error for that fetch.

During denormalization a validation failure will mark that result as 'invalid' and thus will block on fetching a result.

By default does some basic field existance checks in development mode only. Override to disable or customize.

Using validation for endpoints with incomplete fields