Sitemap
Netanel Basal

Learn development with great articles

Simplifying Client-Side Logic: The Strategic Use of View Models

--

Applications often require different data representations from what’s directly available from the server. For instance, consider a User object that includes properties like an enum role, enum status, name, and other properties. Directly using these server-side models in the client-side views often leads to complex transformations scattered throughout the UI logic.

To tackle this, I advocate for creating a View Model class for each entity. This encapsulation simplifies data handling and ensures the UI only receives what it needs. Here’s a simplified real-life example of a UserVm:

const rolesMap = fromBeEnum(UserRoleEnum, {
VIEWER: 'Viewer',
ADMIN: 'Admin',
SIGNER: 'Signer',
OWNER: 'Owner'
});

export class UserVm {
constructor(dto: User) {
this.displayName = dto.name || dto.email;
this.isActive = dto.status === UserStatusEnum.ACTIVE;
this.isServiceAccount = dto.type === UserTypeEnum.SERVICE_ACCOUNT;
this.isViewer = dto.role === UserRoleEnum.VIEWER;
this.isOwner = dto.role === UserRoleEnum.OWNER;
this.isActiveAdmin = dto.isAdmin && this.isActive;
this.typeLabel = dto.isServiceAccount ? 'Service Account' : 'User';
this.roleLabel = this.userRoleToLabel();
this.hasAddQuorum = hasQuorum(dto, 'add');
this.hasRemoveQuorum = hasQuorum(dto, 'remove');
this.isPendingApproval = this.hasAddQuorum || this.hasRemoveQuorum;
}

private userRoleToLabel() {
return rolesMap[this.dto.role] || 'Unknown';
}
}

Upon receiving data from the getUser endpoint, we instantiate the UserVm class to create a new user view model tailored for the response. This abstraction allows for a clean and maintainable code structure.

import { injectQuery, mapResultData } from '@ngneat/query';

@Injectable({ providedIn: 'root' })
export class UserService {
private query = injectQuery();
private client = injectApiClient();

getUser({ vaultId }) {
return this.query({
queryKey: ['user'],
queryFn: () => {
return this.client.getAuthenticatedUser({ vaultId })
}
}).result$.pipe(
mapResultData(user => new UserVm())
)
}
}

This principle is similarly applied when dealing with arrays of entities. For example, consider how transactions are handled within the application — a complex entity that undergoes significant UI transformations:

import { injectQuery, mapResultData } from '@ngneat/query';

@Injectable({ providedIn: 'root' })
export class TransactionsService {
private query = injectQuery();
private client = injectApiClient();

getTransactions({ vaultId }) {
return this.query({
queryKey: ['transactions'],
queryFn: () => {
return this.client.getTransactions({ vaultId })
}
}).result$.pipe(
mapResultData(res => res.transactions.map(t => new TransactionVm(t)))
)
}
}

Benefits of Using View Models

Clarity and Customization

We’ve all encountered those moments when backend naming conventions leave us scratching our heads 😛. View Models allow for the renaming of properties to make them more intuitive. If the DTO names are unclear, VMs provide a way to use more descriptive names without altering the DTO directly.

Schema Flexibility

VMs enable developers to modify the DTO schema to better fit the application’s needs without affecting the backend structure. This flexibility is crucial for adapting to various use cases.

Centralized Changes

If there’s a breaking change or an unexpected modification in the DTO, adjustments need only be made in the View Model, not throughout the application. This centralization significantly reduces bugs and maintenance overhead.

Encapsulation

Encapsulating the transformation logic within the VM reduces the reliance on pipes, utility functions, and enums throughout the component, leading to cleaner and more maintainable code.

Reusability

Once defined, a View Model can be reused in different parts of the application or even in other projects. This reusability can significantly speed up development time and reduce errors. For instance, consider the following scenario with a DeviceVm that incorporates an ownerVm:

export class DeviceVm {
ownerVm: UserVm;

constructor(device: Device) {
// ...device props
this.ownerVm = new UserVm(device.owner);
}
}

This setup allows any part of the application that handles devices to automatically benefit from the rich, pre-processed data available in the UserVm.

Complex data structures, where entities are related, can benefit immensely from composed VMs. For example, a user with multiple devices scenario might look like this:


export class UserWithDevicesVm extends UserVm {
devices: DeviceVm[];

constructor(user: User, devices: Device[]) {
super(user); // Initialize the base UserVm
this.devices = devices.map(device => new DeviceVm(device));
}
}

// In your service
import { intersectResults$ } from '@ngneat/query';

combineLatest([
this.usersService.getUsers({ vaultId }),
this.devicesService.getDevices({ vaultId })
]).pipe(
intersectResults$(([users, devices]) => {
const userDevices = devices.filter(...);
return users.map(user => new UserWithDevicesVm(user, userDevices));
})
)

Cloning View Models for Updates

At times, it becomes necessary to update a VM. For such instances, we can preserve the original DTO and implement a cloning method:

export class DeviceVm {

constructor(private dto: Device) {
// device props
}

clone() {
return new DeviceVm(this.dto)
}
}

Although in my applications I typically don’t update VMs directly. Instead, unless there’s a performance concern, I rely on the invalidateQueries function from @ngneat/query (which utilizes tanstack/query internally). This approach triggers a refetch of data from the server, automatically generates new VMs, and updates the user interface accordingly.

Conclusion

View Models serve as a powerful pattern for managing application data. They not only simplify client-side development but also enhance the maintainability and scalability of the application. By abstracting the data handling and transformations into a dedicated class, developers can focus on building robust and user-friendly interfaces.

Follow me on Medium or Twitter to read more about Angular and JS!

--

--

Netanel Basal
Netanel Basal
Netanel Basal
Netanel Basal

Written by Netanel Basal

A FrontEnd Tech Lead, blogger, and open source maintainer. The founder of ngneat, husband and father.

Responses (5)