Entity
- TypeScript
- JavaScript
import { Entity } from '@rest-hooks/rest';
export default class Article extends Entity {
id: number | undefined = undefined;
title = '';
content = '';
author: number | null = null;
tags: string[] = [];
pk() {
return this.id?.toString();
}
static key = 'Article';
}
import { Entity } from '@rest-hooks/rest';
export default class Article extends Entity {
id = undefined;
title = '';
content = '';
author = null;
tags = [];
pk() {
return this.id?.toString();
}
static key = 'Article';
}
Entity
is an abstract base class used to define data with some form of primary key (or pk
for short).
When representing data from a relational database, this makes an Entity roughly map 1:1 with a table, where
each row represents an instance of the Entity.
By defining a pk()
member, Rest Hooks will normalize entities, ensuring consistency and improve performance
by increasing cache hit rates.
Entities are bound to Endpoints using createResource.schema or RestEndpoint.schema
If you already have your classes defined, schema.Entity mixin can also be used to make Entities.
Data lifecycle
Methods
abstract pk: (parent?, key?, args?): pk?
PK stands for primary key and is intended to provide a standard means of retrieving
a key identifier for any Entity
. In many cases there will simply be an 'id' field
member to return. In case of multicolumn you can simply join them together.
undefined value
A undefined
can be used as a default to indicate the entity has not been created yet.
This is useful when initializing a creation form using Entity.fromJS()
directly. If pk()
resolves to null it is considered not persisted to the server,
and thus will not be kept in the cache.
Other uses
While the pk()
definition is key (pun intended) for making the normalized cache work;
it also becomes quite convenient for sending to a react element when iterating on
list results:
//....
return (
<div>
{results.map(result => (
<TheThing key={result.pk()} thing={result} />
))}
</div>
);
Singleton Entities
What if there is only ever once instance of a Entity for your entire application? You
don't really need to distinguish between each instance, so likely there was no id
or
similar field defined in the API. In these cases you can just return a literal like
'the_only_one'.
pk() {
return 'the_only_one';
}
static key: string
This defines the key for the Entity itself, rather than an instance. This needs to be a globally unique value.
This defaults to this.name
; however this may break in production builds that change class names.
This is often know as class name mangling.
In these cases you can override key
or disable class mangling.
class User extends Entity {
id = '';
username = '';
pk() {
return this.id;
}
static key = 'User';
}
static fromJS(props): Entity
Factory method that copies props to a new instance. Use this instead of new MyEntity()
process(input, parent, key): 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
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 Rest Hooks store.
How to override to build reverse-lookups for relational data
static mergeWithStore(existingMeta, incomingMeta, existing, incoming): mergedValue
static mergeWithStore(
existingMeta: { date: number; fetchedAt: number },
incomingMeta: { date: number; fetchedAt: number },
existing: any,
incoming: any,
): any;
mergeWithStore()
is called during normalization when a processed entity is already found in the store.
This calls useIncoming() and potentially merge()
static useIncoming(existingMeta, incomingMeta, existing, incoming): boolean
static useIncoming(
existingMeta: { date: number; fetchedAt: number },
incomingMeta: { date: number; fetchedAt: number },
existing: any,
incoming: any,
) {
return existingMeta.fetchedAt <= incomingMeta.fetchedAt;
}
Override this to change the algorithm - for instance if having the absolutely correct latest value is important, adding a timestamp to the entity and then using it to select the return value will solve any race conditions.
Example
class LatestPriceEntity extends Entity {
readonly id: string = '';
readonly timestamp: string = '';
readonly price: string = '0.0';
readonly symbol: string = '';
static useIncoming(
existingMeta: { date: number; fetchedAt: number },
incomingMeta: { date: number; fetchedAt: number },
existing: any,
incoming: any,
) {
return existing.timestamp <= incoming.timestamp;
}
}
Preventing updates
useIncoming can also be used to short-circuit an entity update.
import deepEqual from 'deep-equal';
class LatestPriceEntity extends Entity {
readonly id: string = '';
readonly timestamp: string = '';
readonly price: string = '0.0';
readonly symbol: string = '';
static useIncoming(
existingMeta: { date: number; fetchedAt: number },
incomingMeta: { date: number; fetchedAt: number },
existing: any,
incoming: any,
) {
return !deepEqual(incoming, existing);
}
}
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 summary endpoints
static infer(args, indexes, recurse): pk?
Allows Rest Hooks to build a response without having to fetch if its entities can be found.
Returning undefined
will not infer this entity
Returning pk
string will attempt to lookup this entity and use in the response.
When inferring a response, this entity's expiresAt is used to compute the expiry policy.
By default uses the first argument to lookup in pk() and indexes
static expiresAt(meta: { expiresAt: number; date: number }, input: any): expiresAt
This determines expiry time when entity is part of a result that is inferred.
Overriding can be used to change TTL policy specifically for inferred responses.
Members
static indexes?: (keyof this)[]
Indexes enable increased performance when doing lookups based on those parameters. Add
fieldnames (like slug
, username
) to the list that you want to send as params to lookup
later.
Note:
Don't add your primary key like
id
to the indexes list, as that will already be optimized.
useSuspense()
With useSuspense() this will eagerly infer the results from entities table if possible, rendering without needing to complete the fetch. This is typically helpful when the entities cache has already been populated by another request like a list request.
export class User extends Entity {
readonly id: number | undefined = undefined;
readonly username: string = '';
readonly email: string = '';
readonly isAdmin: boolean = false;
pk() {
return this.id?.toString();
}
static urlRoot = 'http://test.com/user/';
// right here
static indexes = ['username' as const];
}
export const UserResource = createResource({
path: '/user/:id',
schema: User,
});
const user = useSuspense(UserResource.get, { username: 'bob' });
useCache()
With useCache(), this enables accessing results retrieved inside other requests - even if there is no endpoint it can be fetched from.
class LatestPrice extends Entity {
readonly id: string = '';
readonly symbol: string = '';
readonly price: string = '0.0';
static indexes = ['symbol'] as const;
}
const latestPriceFromCache = new Index(LatestPrice);
class Asset extends Entity {
readonly id: string = '';
readonly price: string = '';
static schema = {
price: LatestPrice,
};
}
const getAssets = new RestEndpoint({
path: '/assets',
schema: [Asset],
});
Some top level component:
const assets = useSuspense(getAssets);
Nested below:
const price = useCache(latestPriceFromCache, { symbol: 'BTC' });
static schema: { [k: keyof this]: Schema }
Defines related entity members, or field deserialization like Date and BigNumber.
{"id":"5","author":{"id":"123","name":"Jim"},"content":"Happy day","createdAt":"2019-01-23T06:07:48.311Z"}
export class User extends Entity {id = '';name = '';pk() {return this.id;}}export class Post extends Entity {id = '';author = User.fromJS({});createdAt = new Date(0);content = '';title = '';static schema = {author: User,createdAt: Date,};pk() {return this.id;}}export const getPost = new RestEndpoint({path: '/posts/:id',schema: Post,});function PostPage() {const post = useSuspense(getPost, { id: '123' });return (<div><p>{post.content} - <cite>{post.author.name}</cite></p><time>{Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(post.createdAt,)}</time></div>);}render(<PostPage />);
Optional members
Entities references here whose default values in the Record definition itself are considered 'optional'
class User extends Entity {
readonly friend: User | null = null; // this field is optional
readonly lastUpdated: Date = new Date(0);
static schema = {
friend: User,
lastUpdated: Date,
};
}