Entity and Data Normalization
Entities have a primary key. This enables easy access via a lookup table. This makes it easy to find, update, create, or delete the same data - no matter what endpoint it was used in.
- State
- Response
- Endpoint
- Entity
- React
[
{ "id": 1, "title": "this is an entity" },
{ "id": 2, "title": "this is the second entity" }
]
const getPresentations = new Endpoint(
() => fetch(`/presentations`).then(res => res.json()),
{ schema: new schema.Collection([Presentation]) },
);
class Presentation extends Entity {
id = '';
title = '';
static key = 'Presentation';
}
export function PresentationsPage() {
const presentation = useSuspense(getPresentations);
return presentation.map(presentation => (
<div key={presentation.pk()}>{presentation.title}</div>
));
}
Extracting entities from a response is known as normalization
. Accessing a response reverses
the process via denormalization
.
Using entities expands Reactive Data Client' global referential equality guarantee beyond the granularity of an entire endpoint response.
Mutations and Dynamic Data
When an endpoint changes data, this is known as a side effect. Marking an endpoint with sideEffect: true tells Reactive Data Client that this endpoint is not idempotent, and thus should not be allowed in hooks that may call the endpoint an arbitrary number of times like useSuspense() or useFetch()
By including the changed data in the endpoint's response, Reactive Data Client is able to able to update any entities it extracts by specifying the schema.
- Create
- Update
- Delete
import { RestEndpoint, schema } from '@data-client/rest';
const todoCreate = new RestEndpoint({
urlPrefix: 'https://jsonplaceholder.typicode.com',
path: '/todos',
method: 'POST',
schema: new schema.Collection([Todo]).push,
});
Example Usage
import { useController } from '@data-client/react';
export default function NewTodoForm() {
const ctrl = useController();
return (
<Form
onSubmit={e => ctrl.fetch(todoCreate, new FormData(e.target))}
>
<FormField name="title" />
</Form>
);
}
import { RestEndpoint } from '@data-client/rest';
const todoUpdate = new RestEndpoint({
urlPrefix: 'https://jsonplaceholder.typicode.com',
path: '/todos/:id',
method: 'PUT',
schema: Todo,
});
Example Usage
import { useController } from '@data-client/react';
export default function UpdateTodoForm({ id }: { id: number }) {
const todo = useSuspense(todoDetail, { id });
const ctrl = useController();
return (
<Form
onSubmit={e =>
ctrl.fetch(todoUpdate, { id }, new FormData(e.target))
}
initialValues={todo}
>
<FormField name="title" />
</Form>
);
}
import { schema, RestEndpoint } from '@data-client/rest';
const todoDelete = new RestEndpoint({
urlPrefix: 'https://jsonplaceholder.typicode.com',
path: '/todos/:id',
method: 'DELETE',
schema: new schema.Invalidate(Todo),
});
Example Usage
import { useController } from '@data-client/react';
export default function TodoWithDelete({ todo }: { todo: Todo }) {
const ctrl = useController();
return (
<div>
{todo.title}
<button onClick={() => ctrl.fetch(todoDelete, { id: todo.id })}>
Delete
</button>
</div>
);
}
Mutations automatically update the normalized cache, resulting in consistent and fresh data.
Schema
Schemas are a declarative definition of how to process responses
- where to expect Entities
- Functions to deserialize fields
import { RestEndpoint, schema } from '@data-client/rest';
const getTodoList = new RestEndpoint({
urlPrefix: 'https://jsonplaceholder.typicode.com',
path: '/todos',
schema: new schema.Collection([Todo]),
});
Placing our Entity Todo
in an array Collection, allows us to easly
push or unshift new Todos
on it.
Aside from array, there are a few more 'schemas' provided for various patterns. The first two (Object and Array) have shorthands of using object and array literals.
Data Type | Mutable | Schema | Description | Queryable |
---|---|---|---|---|
Object | ✅ | Entity | single unique object | ✅ |
✅ | Union(Entity) | polymorphic objects (A | B ) | ✅ | |
🛑 | Object | statically known keys | 🛑 | |
Invalidate(Entity) | delete an entity | 🛑 | ||
List | ✅ | Collection(Array) | growable lists | ✅ |
🛑 | Array | immutable lists | 🛑 | |
✅ | All | list of all entities of a kind | ✅ | |
Map | ✅ | Collection(Values) | growable maps | ✅ |
🛑 | Values | immutable maps | 🛑 | |
any | Query(Queryable) | memoized custom transforms | ✅ |
Nesting
Additionally, Entities themselves can specify nested schemas by specifying a static schema member.
- Entity
- Response
import { Entity } from '@data-client/endpoint';
class Todo extends Entity {
id = 0;
user = User.fromJS();
title = '';
completed = false;
static key = 'Todo';
static schema = {
user: User,
};
}
class User extends Entity {
id = 0;
username = '';
static key = 'User';
}
{
"id": 5,
"user": {
"id": 10,
"username": "bob"
},
"title": "Write some Entities",
"completed": false
}
Data Representations
Additionally, functions can be used as a schema. This will be called during denormalization. This might be useful with representations like bignumber or temporal instant
import { Entity } from '@data-client/endpoint';
class Todo extends Entity {
id = 0;
user = User.fromJS();
title = '';
completed = false;
dueDate = Temporal.Instant.fromEpochSeconds(0);
static key = 'Todo';
static schema = {
user: User,
dueDate: Temporal.Instant.from,
};
}
Due to the global referential equality guarantee - construction of members only occurs once per update.
Store Inspection (debugging)
DevTools browser extension can be installed to inspect and debug the store.
Benchmarks
Here we compare denormalization performance with the legacy normalizr library, which has less features, but similar schema definitions.
Memoization is done at every entity level - no matter how nested, ensuring global referential equality guarantees and up to 20x performance even after mutation operations like Create, Update and Delete.