View Pattern is a front-end design pattern. The View Pattern is a way to automatically inject a view corresponding to the asynchronous request state. For example, a component that depends on a data retrieved by an HTTP request will start with a loading state then depends on the resolved state (error or success) it will switch to main or the error view.
If you are a web front-end developer you may recognize a repetitively UI pattern of displaying a loader while an asynchronous request is being processed, then switching do the main view or displaying and error. Personally, I noticed that in single page applications with multiple components per page that being loaded independently I have a repetitive code. And even worst, is the fact that I am not getting any indication for missing templates (if I forgot to implement an error handler or a loader).
In this short article I am going to share my "View Pattern" solution attempting to reduce code duplication and notify for missing parts.
The View
in "View Pattern"
To achieve the goal of a reusable view pattern we need to start by defining an interface to store the View
state. This view state can be of any complexity, but for this article I will focused the following states:
Loading - the state before the asynchronous request has been resolved. This state will inject the Loader
template.
Data - upon a (successful) response the display data will be mapped into the main
template.
Error - if the request failed the error state will contain the reason for the failure and instructions for the error
template display.
export class View<T> {
data?: T; // Store component data (of generic type T)
loader?: boolean; // Whether to show the loader
error?: Error;
}
In this specific implementation I am going to use RxJS Observables to handle the async events and manipulate the flow.
For every event coming out our main event emitter we will wrap it in a View
state. Since http is the most used observable we will use it for our example: const view$: Observable<View<T>> = this.httpClient<T>(<url>)
.
We will startWith
emitting the loading state. Then when we receive the response event (with the data T
) we will map
it to View<T>
. For handling errors we will add catchError
.
const request$: Observable<View<T>> = this.httpClient<T>(<url>).pipe(
startWith({loader: true}),
map(response => ({data: response})),
catchError(error => of({error})));
Note:
-
T
is a placeholder for the response type
"View Pattern" viewContainer
The ViewContainer
is responsible to inject the correct template for a given view. In this tutorial we will use a structural directive as an example.
The usage will look as follows:
<div *viewContainer="view$ | async;
main mainTmp;
error errorTmp;
loading loaderTmp">
<div>
<ng-template #mainTmp>...</ng-template>
<ng-template #errorTmp>...</ng-template>
<ng-template #loaderTmp>...</ng-template>
<view-container
*ngIf="view$ | async as view"
[appViewMain]="mainTmp"
[errorTmp]="errorTmp"
[loaderTmp]="loaderTmp"
[view]="view">
</view-container>
<ng-template #mainTmp>...</ng-template>
<ng-template #errorTmp>...</ng-template>
<ng-template #loaderTmp>...</ng-template>
In the next part we are going to implement that structural directive. But, it could also be a component. If you are interested you can find full implementations.
viewContainer
Implementation
First let's create our Directive
@Directive({ selector: '[viewContainer]' })
export class ViewContainerDirective<T> implements AfterViewInit {
ngAfterViewInit(): void {
// Verify all the templates defined, throw an error otherwise
}
}
Next, define the properties to save the reference templates
private _mainTemplateRef: TemplateRef<AppViewContext<T>> = null;
private _errorTemplateRef: TemplateRef<AppViewContext<T>> = null;
private _loaderTemplateRef: TemplateRef<AppViewContext<T>> = null;
and bind the template reference (#<name>
) to the properties.
@Input() set viewContainerMain(templateRef: TemplateRef<any>) {
this._mainTemplateRef = templateRef;
}
@Input() set viewContainerError(templateRef: TemplateRef<any>) {
this._errorTemplateRef = templateRef;
}
@Input() set viewContainerLoading(templateRef: TemplateRef<any>) {
this._loaderTemplateRef = templateRef;
}
In case you wonder how that binding works check the microsyntax for directives. In short, the setter name is a combination of the directive name (prefix) with the attribute name (suffix).
Now, let's go back to the ngAfterViewInit
and add the check if one of the templates is missing
ngAfterViewInit(): void {
if (!this._errorTemplateRef) throw new Error('View Pattern: Missing Error Template')
if (!this._loaderTemplateRef) throw new Error('View Pattern: Missing Loader Template')
if (!this._mainTemplateRef) throw new Error('View Pattern: Missing Main Template')
}
Finally, each time the View
is changed insert the template to the container. For that we can use createEmbeddedView
API So let's inject the ViewContainerRef
Service.
constructor(private _viewContainer: ViewContainerRef) { }
One of createEmbeddedView
optional parameters is a context. Providing the context will allow accessing the data (T
- the one from the View<T>
).
private _context: AppViewContext<T> = new AppViewContext<T>();
Now, we have everything we need to implement the setter:
@Input() set viewContainer(view: View<T>) {
if (!view) return;
this._context.$implicit = view; // setting view to be avilable in the template
this._viewContainer.clear(); // Clears the old template before setting the the new one.
if (view.loader)
this._viewContainer.createEmbeddedView(this._loaderTemplateRef, this._context);
if (view.error && !view.loader) // Defines the conditions to display each template in single place
this._viewContainer.createEmbeddedView(this._errorTemplateRef, this._context);
if (view.data && !view.error)
this._viewContainer.createEmbeddedView(this._mainTemplateRef, this._context);
}
Wrapping Up
In this tutorial we implemented the "view pattern" allowing us to simplify our components by reducing duplicated code, flattening the templates. While at the same time reducing the chances for potential bugs by getting some feedback when something is missing.
That pattern can be easily extended to support more complicated states, and it will support "skeleton loaders" out of the box by providing both the mock data
and the loader
. You can check the full code and examples on Github.
Top comments (0)