ViewModel & Caching
The @prestojs/viewmodel
package is a fundamental part of the Presto library. It's core concept is the ViewModel.
The ViewModel is a class that outlines the field types and associated metadata for a set of data.
The goal of ViewModel is to abstract data representation from its source or backend implementation. It allows for automatic UI generation and advanced caching.
Field Classes
Presto provides a base Field
class for describing data fields:
new Field({ label: 'Email' });
However, it's common to use more specific field classes to reflect data types:
new IntegerField({ label: 'Age' });
If you don't specify label
in the field options, it will be inferred from the field name.
Using specific field types allows the UI to tailor its input methods accordingly. For instance, an IntegerField could generate a numeric input on a form and apply locale-specific formatting on a detail view. More metadata can be specified:
new BooleanField({ label: 'Received Updates', helpText: 'Receive periodic updates from us', defaultValue: true, });
This metadata can be used to generate UI elements automatically. For example, a form could be generated with a checkbox with the specified label and add some help text underneath it.
ViewModel concerns itself with defining ViewModel's and caching. Actual UI generation is handled with components from @prestojs/antd and @prestojs/antd, or your own custom components.
See the base Field documentation for the generic options available on all fields.
You can find the list of provided fields in the ViewModel documentation under 'Fields' in the sidemenu.
You can create your own custom fields. See Creating custom fields for more information.
ViewModel Factory Function
To create a ViewModel, Presto offers a factory function where you define the fields and options, like the primary key for the model.
const fields = { userId: new IntegerField({ label: 'User ID' }), firstName: new CharField({ label: 'First Name' }), lastName: new CharField(), }; const options = { pkFieldName: 'userId', }; class User extends viewModelFactory(fields, options) { static cache = new MyCustomCache(); static label = 'User'; static labelPlural = 'Users'; }
In the above example, User
class is created by extending the result of the viewModelFactory
function with defined fields and options. The fields
dictionary contains instances of field types, representing the
structure of the data. The options object must contain pkFieldName
specifying the primary key field.
ViewModel Options
pkFieldName
- (required) Specifies the primary key field(s). Accepts a string or an array of strings for compound keys.
// Single primary key pkFieldName: 'userId', // Compound key pkFieldName: ['organisationId', 'departmentId'],
baseClass
- (optional) Specifies a base class for the model. Automatically set to the class being augmented when using augment.
ViewModel Properties
cache
- (optional) Defines the cache to be used. By default, Presto provides a suitable cache, but it can be replaced with a custom one if needed.label
- (optional) Describes a single instance of the model. This can be used for generating UI elements, for example the title of a detail view.labelPlural
- (optional) Describes multipl instance of the model. This can be used for generating UI elements, for example the title of a list view.
See the BaseViewModel class for all the available properties.
Augmenting
Augmenting in is a way to extend your existing models while keeping the integrity of the original
class intact. This includes merging the fields from both classes so that properties like fieldNames
reflect all fields.
The process also ensures that caching works seamlessly between the base class and the augmented one.
A significant use case of augmenting is code generation for creating base classes that reflect backend structures (like Django models). You can then add frontend customizations to these base classes using augmenting.
Augment Method
The augment
method is called on an existing model and takes two arguments: newFields
and newOptions
.
User.augment(newFields, newOptions);
The newFields
argument is similar to the fields
object passed to the viewModelFactory
function. It contains a
dictionary of new field definitions you want to add or overwrite in the base model.
To remove a field from the base model in the augmented version, you can pass null
as the field's value:
newFields = { email: null, // Removes 'email' field from base model age: new IntegerField({ label: 'Age' }), // Adds or overwrites 'age' field };
The newOptions
argument is an optional parameter that takes an object similar to the options object in viewModelFactory
.
If not provided, newOptions
defaults to using the pkFieldName
defined on the root model.
NOTE: While
newOptions
is optional, it's recommended to always provide it with an explicitpkFieldName
to avoid issues with typescript types.
Code Generation and Base Class Customization
One of the significant benefits of augmenting is the ease it brings to code generation, especially when your base class reflects a backend model (e.g., Django).
You can use a code generator to create the base classes based on your backend models. Afterward, augment these base classes to tailor them to your frontend needs:
// Codegen creates this in `BaseUser.ts` and will overwrite it whenever changes are made to the backend model class BaseUser extends viewModelFactory(backendFields, backendOptions) {} // You customize it in `User.ts` class User extends BaseUser.augment(customFields, customOptions) {}
This pattern keeps your code DRY and ensures consistency between your backend and frontend structures while allowing for frontend-specific customizations.
Codegen like this could have many sources. The most obvious is database records directly, but often it's better to target the interface that's exposed by the backend API. For example, in django the records might be serialised using Django Rest Framework. Having the frontend records match the serializer rather than the model makes more sense.
Rather than thinking of
ViewModel
as corresponding to a database model, think of it as a representation of any typed data.
Modeling Relationships
In @prestojs/viewmodel
, you can define relationships between different models using the RelatedViewModelField
or ManyRelatedViewModelField. These fields provide a seamless way to map and manage relations
between different data models in your application.
Single Related ViewModel
A single relation between two models can be established using the RelatedViewModelField. This is suitable for one-to-one or many-to-one relationships.
For instance, suppose we have a User
model and a PhoneNumber
model, where each PhoneNumber
instance is related to aUser
:
class PhoneNumber extends viewModelFactory( { id: new IntegerField(), phoneNumber: new CharField(), userId: new IntegerField(), user: new RelatedViewModelField({ to: User, sourceFieldName: 'userId' }), }, { pkFieldName: 'id' } ) {}
In this example, user is a RelatedViewModelField
that points to a User
instance. The sourceFieldName
parameter is
set to 'userId', indicating that userId
could be used to lookup the instance of User
. The sourceFieldName
is always required.
This will be particularly important when working with caching, discussed later.
Handling Circular References
Circular references are supported by passing a function that returns a ViewModel to to
. This comes in handy when two
models need to reference each other:
class User extends viewModelFactory( { id: new IntegerField(), name: new CharField(), emailAddress: new EmailField(), defaultPhoneNumberId: new IntegerField(), defaultPhoneNumber: new RelatedViewModelField({ to: () => PhoneNumber, sourceFieldName: 'defaultPhoneNumberId', }), }, { pkFieldName: 'id' } ) {} class PhoneNumber extends viewModelFactory( { id: new IntegerField(), phoneNumber: new CharField(), userId: new IntegerField(), user: new RelatedViewModelField({ to: User, sourceFieldName: 'userId' }), }, { pkFieldName: 'id' } ) {}
Promises are supported in
to
as well. This is useful for lazy loading imports, however is considered an advanced use case. See the RelatedViewModelField docs for examples.
Many-to-many relationships
If a model is related to many instances of another model, you can represent this using the ManyRelatedViewModelField.
This is suitable for many-to-many relationships, or the reverse of a RelatedViewModelField
.
The sourceFieldName
for ManyRelatedViewModelField
should reference a ListField that contains all the
related instance IDs:
userIds: new ListField({ childField: new IntegerField({}), }), users: new ManyRelatedViewModelField({ to: User, sourceFieldName: 'userIds' }),
Working with ViewModel Instances
You can create an instance by instantiating ViewModel class with an object that provides the field values. The primary key is always required, but all other fields are optional:
const user = new User({ id: 1, name: 'Dave' });
Partial Models
Partial models are instances where not all fields are specified. If a value isn't supplied for a field, the instance is
considered "partial" for that field. Note that a field with a null
value is not considered partial - it's only partial
if no value is supplied at all.
To inspect which fields are specified in an instance, you can check the _assignedFields
property:
console.log(user._assignedFields); // Output: ['id', 'name']
Accessing Field Values
Once a ViewModel instance is initialized, the fields it was initialized with can be accessed by their names:
console.log(user.name); // Output: 'Dave'
The primary key value is available on it's name (e.g. id
) but can also be accessed generically on the _key
property.
This is useful when writing generic components that accept a ViewModel instance and need access to the primary key.
To access the Field
instances themselves, you need to go via ViewModel.getField
, e.g., User.getField('name')
. From
a model instance, you can get its ViewModel
class using _model
:
console.log(user._model === User); // Output: true
Accessing Uninitialized Fields
Attempting to access a field that the instance was not initialized with will result in an error:
console.log(user.emailAddress); // Throws Error: 'emailAddress' accessed on User but was not instantiated with it. Available fields are: ...
The _f Property
The _f property, short for fields, holds 'Bound' instances of Field
with a value
property. For example, user._f.name
would
return an instance of CharField
, and user._f.name.value
would return 'Dave':
console.log(user._f.name); // Output: CharField instance console.log(user._f.name.value); // Output: 'Dave'
The _f
property is useful when you want to pass a field and its value around as a single entity, without having to separate them.
For example, FieldFormatter supports passing a bound field rather than an unbound field and value.
Including Related Records
Related records can be included when creating an instance:
const user = new User({ id: 1, name: 'Dave', defaultPhoneNumber: new PhoneNumber({ id: 2, phoneNumber: '999999' }), });
In this case, user.defaultPhoneNumberId
will be automatically set to match user.defaultPhoneNumber.id
.
To see the fields in nested records, you can inspect _assignedFieldsDeep
:
console.log(user._assignedFieldsDeep); // Output: [["defaultPhoneNumber","id"],["defaultPhoneNumber","phoneNumber"],"defaultPhoneNumberId","id","name"]
ViewModel Caching
ViewModel caching is a core feature designed to manage and track changes in your data across components, making your applications more responsive and efficient. By caching your data, you can listen to changes and update your components in real-time as your data changes. This can make managing the flow of data easier by having a single source of truth.
Benefits of Caching
- Component Rerendering: Whenever your data changes, your components are automatically rerendered. For instance, when a user record is updated, any component displaying this user's information is refreshed instantly.
- Cross-Component Updates: Changes in one component can affect the data displayed in another. For instance, if one component creates a new user record, any other component displaying user information is updated to include the new user.
- Real-time Updates: Real-time updates from other users are made easier through caching. If you use websockets for real-time data communication, updating the cache could be as simple as having the websocket listener write to the cache.
How Caching Works
Caching is based on the primary key, and the fields that are specified when creating an instance. For example, the following two instances are cached separately:
const user1 = User.cache.add({ id: 1, name: 'John' }); const user2 = User.cache.add({ id: 1, email: 'john@example.com' });
When you read from the cache you specify the fields (or "*"
for all fields):
User.cache.get(1, ['name']); // { id: 1, name: 'John' } User.cache.get(1, ['email']); // id: 1, email: 'john@example.com' User.cache.get(1, '*'); // null; cache miss because there's no cached record with all fields User.cache.add({ id: 1, name: 'John', email: 'john@example.com' }); const john = User.cache.get(1, '*'); // id: 1, name: 'John', email: 'john@example.com'
You can also pass an instance of the ViewModel to get the latest version from the cache:
const latest = User.cache.get(john); // If there's been no changes to the record, the same instance is returned. Otherwise the // latest version will be returned. latest === john;
An update to a superset of fields will update all cached subsets:
User.cache.add({ id: 1, name: 'Johnny Smith', email: 'johnny@test.com', }); console.log(User.cache.get(1, ['id', 'name'])); // { id: 1, name: 'Johnny Smith' } console.log(User.cache.get(1, ['id', 'email'])); // { id: 1, email: 'johnny@test.com' }
The motivation for this behaviour is that it's more desirable for have records be internally consistent than to have each individual field reflect the latest value. Having partial records is useful for restricting the amount of data that is sent to the frontend.
You don't have to use partial records everywhere. In many cases you can include all fields in the cache and things will generally be simpler. However, if you have a large number of fields, or you have a lot of records, you may want to embrace using partial records to reduce the amount of data fetched.
Note that if you request fields that aren't in the cache you will get no result (a cache miss). The primary key is always included, so it is optional to include it in the list of fields.
Using ViewModel Cache
Each ViewModel
comes with a cache property, an instance of ViewModelCache
. Here are some examples of how you can use the cache:
Adding a record
User.cache.add(new User({ id: 1, name: 'John' })); // Passing an instance is optional, you can just pass the data directly. // An instance of the ViewModel is always returned. const user = User.cache.add({ id: 1, name: 'John' });
Retrieving a record
When you retrieve a record you specify which fields you care about:
const record = User.cache.get(1, ['id', 'name']);
Or use "*"
to get all fields.
const record = User.cache.get(1, '*');
Updating a record
This is the same as adding a record. If it already exists in the cache, it will be updated.
User.cache.add({ id: 1, name: 'Johnny' });
Supersets and Subsets (Partial Records)
The cache is managed per unique set of fields, but a superset will update a subset
User.cache.add({ id: 1, name: 'Johnny Smith', email: 'johnny@test.com', }); console.log(User.cache.get(1, ['id', 'name'])); // { id: 1, name: 'Johnny Smith' } console.log(User.cache.get(1, ['id', 'name', 'email'])); // { id: 1, name: 'Johnny Smith', email: 'johnny@test.com' }
Updating just the name
will leave the other instance in a consistent state:
User.cache.add({ id: 1, name: 'Jon', }); console.log(User.cache.get(1, ['id', 'name'])); // { id: 1, name: 'Jon' } // This instance remains unchanged console.log(User.cache.get(1, ['id', 'name', 'email'])); // { id: 1, name: 'Johnny Smith', email: 'johnny@test.com' }
Deleting from Cache
You can delete specific caches for a subset of fields, or all fields:
User.cache.delete(1, ['id', 'name']); console.log(User.cache.get(1, ['id', 'name'])); // null User.cache.delete(1); console.log(User.cache.get(1, ['id', 'name', 'email'])); // null
Adding or Updating Multiple Records
This works the same as for a single record, but you pass an array instead:
User.cache.addList([johnny, sam]);
Listing to changes
You can listen to changes for a single or multiple records:
User.cache.addListener(2, ['id', 'name'], (previous, next) => console.log(previous, 'change to', next) ); User.cache.addListenerList([3, 4], ['id', 'name'], (previous, next) => console.log(previous, 'change to', next) );
Caching & Related Fields
Fetching related fields is a common use case. For example, here we have a User
that belongs to multiple Group
records:
class Group extends viewModelFactory( { id: new IntegerField(), name: new CharField(), }, { pkFieldName: 'id' } ) {} class User extends viewModelFactory( { id: new IntegerField(), name: new CharField(), groupIds: new ListField({ childField: new IntegerField() }), groups: new ManyRelatedViewModelField({ to: Group, sourceFieldName: 'groupIds', }), }, { pkFieldName: 'id' } ) {}
Related records can be added by nesting data and the corresponding caches will be updated:
const user = User.cache.add({ id: 1, name: 'Dave', groups: [{ id: 1, name: 'Tech Support' }], });
This adds an entry into User
and another entry into Group
for the "Tech Support" group:
console.log(Group.cache.get(4, ['name'])); Output: { id: 1, name: "Tech Support" }
Fetching the related fields reads all data from the relevant caches. Here the groups are read from the Group
cache:
User.cache.get(1, ['name', 'groups']); // Output: // { // groupIds: [ // 1, // 4 // ], // id: 1, // name: Dave, // groups: [ // { // id: 1, // name: Admins // }, // { // id: 4, // name: Tech Support // } // ] // }
Any changes made to the group via the Group
cache will be reflected in the User
record fetched from the User
cache.
Using useViewModelCache React Hook
The useViewModelCache React hook provides an easy way to interact with your ViewModel cache. This hook triggers a re-render of your component whenever the associated cache data changes, ensuring your component always displays the most recent data.
Basic Usage
The useViewModelCache
hook takes a ViewModel
class as the first argument and a selector function as the second argument.
The selector function return the data you want from the cache.
const user = useViewModelCache(User, cache => cache.get(1, ['name', 'email']));
Each time the cache changes, the selector function will be called. If the returned value differs from its previous result, your component will re-render.
Advanced Usage
The selector function can return any type of data. For instance, you could return user records grouped by a field:
const usersByGroup = cache => cache.getAll(['groupId', 'firstName', 'email']).reduce((acc, record) => { acc[record.groupId] = acc[record.firstName] || []; acc[record.groupId].push(record); return acc; }, {}); const groupedUsers = useViewModelCache(User, usersByGroup);
By default, useViewModelCache
performs a strict equality check to compare the previous and current return values of
the selector function. However, for complex objects that are recreated every time the selector function runs, such as
in the example above, this can lead to unnecessary re-renders. To prevent this, you can provide your own equality function:
import { isDeepEqual } from '@prestojs/util'; const groupedUsers = useViewModelCache(User, usersByGroup, [], isDeepEqual);
In this example, isDeepEqual
is used to perform a deep equality check, avoiding re-renders when the data is identical.
Using Selector Arguments
The third argument of useViewModelCache
is an array of arguments that will be passed to the selector function. This
feature allows you to create reusable selectors:
// Define a selector outside the component that selects a record from a cache const selectRecord = (cache, id, fieldNames) => cache.get(id, fieldNames); const fieldNames = ['name', 'id']; const id = 1; // selectRecord will be called only if id or fieldNames changes const record = useViewModelCache(User, selectRecord, [id, fieldNames]);
Alternatively, you can use an inline function. However, this means the selectRecord
function will be called every time
the containing component or hook renders:
const record = useViewModelCache(user, cache => selectRecord(cache, id, fieldNames));
Selector conventions
Selectors can be stored anywhere, but one common approach is to store them on the ViewModel class itself under a
selectors
static property:
class User extends BaseUser { static selectors = { getById: (cache, id, fieldNames) => cache.get(id, fieldNames), }; }
You can then re-use it throughout your application:
useViewModelCache(User, User.selectors.getById, [1, ['name', 'email']]);
This is just a recommended convention - nothing in Presto relies on this structure.
See the useViewModelCache documentation for full API documentation and examples.