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 explicit pkFieldName 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.

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.

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)
);

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.