-
Notifications
You must be signed in to change notification settings - Fork 0
Angular Implementation
Angular has two form libraries, reactive forms and template-driven forms. The boilerplate only uses reactive forms. There can be debate about this, but basically reactive forms scale better, offer more power, and the boilerplate's Dynamic Form helps make them easier to use.
Reactive forms keep majority the logic in code, with only a couple of directive references going into the template. Reactive forms are passed by reference, so updating the same instance of a form in a child will update it in its parent, and vice versa. This allows shared form components to be very simple, since the parent has the form instance also.
Forms in Angular are built on top of RxJS. The different form pieces are all reactive, that provide different streams that can be subscribed to, like valueChanges
or statusChanges
(this listens to the valid/invalid state). It's possible to subscribe to an entire form, or children FormGroups
, FormArrays
, or FormControls
.
The boilerplate has dynamic form functionality, through a service, a directive, and individual form field templates. This allows a developer to create a form by creating an instance of the form config class, populating it, and passing it to the in the template. The directive is able to parse the provided config object and through a factory generate a form.
This is very useful for use cases where there is a standard set of forms, often for CRUD. The dynamic form is extendible. The form field shared components can be styled and modified or new ones can be added, and the logic for creating forms lives in the FormService
, so creating custom forms outside of the dynamic form is also easy.
Reactive Forms use functions to handle validation. These functions are assigned to the form at the scope of FormControl
or FormGroup
, and consume the provided AbstractControl
, along with any passed in parameters. The validators can perform evaluations, and return either null
(which in this case means there are no errors), or set validation errors. These errors are key/value pairs, and the values can be strings or booleans. Part of the goal of the boilerplate with these validators is to create reusable validation logic, so the validation errors set string values. This way, the error messages can be consolidated rather than having to be created for each instance in the component template.
Along with setting powerful and dynamic validation messages, these validators also set the validity state of the form. Using validators can create a responsive form user experience, with the form providing detailed reactive feedback, and updating its validity state.
Validators are also very easy to unit test, and require no mocks or dependency injection.
Angular is built around the Dependency Injection pattern and services are the vehicle for that pattern. This resource helps break down what Dependency Injection is and how it works in Angular. The boilerplate sorts services into three common use cases: API services, state services, and miscellaneous other services.
All of the API services use ApiService
, which wraps Angular's built in HttpClient
. This allows developers to set up common behaviors for HttpClient
. For example, the ApiService
methods all call shareReplay
to make the observables multicast, or hot, and prevent duplicate streams of data (see here for a more detailed explanation).
Angular provides another tool to manage global HttpClient
behavior: interceptors. The boilerplate has an ApiInterceptor
, which is in charge of setting common Http headers and triggering automatic logout behavior. The interceptor isn't called directly by services. Rather, HttpClient
has to go through it.
The pattern in the boilerplate is to create separate services to match API controllers, so UserService
to match an API UserController
. It has methods to match API endpoints. These API methods are also in charge of side effects, like updating state or sending notifications. It's good to put that logic here rather than in components. It's more reusable, and it separates business logic from UI.
All of the API services return Observables, not promises. Angular's HttpClient
always returns responses as Observables. Part of the reason to use Angular is built-in and widespread reliance on RxJS, and converting responses to promises removes all of the functionality that RxJS can bring.
The boilerplate also has state services. State is managed in the boilerplate through RxJS. (There are other state management tools and libraries, and they are great! The boilerplate uses RxJS because the common use case at Shift3 doesn't usually need the extra complexity these other tools bring, and RxJS is built into Angular).
The state management in the boilerplate is separated into specific state services by preference. It can make it easier to know what to expect within a service. There's no mandate to set it up this way.
The general pattern with the state services is to create a BehaviorSubject for the state value, and then getter (which returns an Observable through .asObservable
), setter and reset methods for the value.
The state values are reactive streams and can be subscribed to throughout the application. BehaviorSubjects are multicast by design, so subscribers get values from the point in time where they subscribe, not from the beginning (this link can help explain this through a very useful analogy).
Like other kinds of state management, BehaviorSubjects do not persist through a browser reload.
There are other miscellaneous services in the boilerplate which do not fit the above categories. Examples include FormService
, which has methods to interact with forms, and ModalService
, which handles creating modals. The other big services stored here are for error handling. Along with handling client-side and server-side errors, there is a SentryErrorHandlerService
that interacts with sending error logs to Sentry, the paid error logging service Shift3 uses. The error handling is set up so that the default behaviors of showing error messages in toast notifications and logging most errors in Sentry happen automatically.
Angular's built-in router is incredibly powerful, and the boilerplate tries to use many of its features. (This is an essential article to understand how Angular's router works).
The router organizes routes into arrays of route objects. These objects set the route path
, the associated component
, configure any guards with can enter (canActivate
, canActivateChild
, and canLoad
) and can leave (canDeactivate
) behavior, pre-fetch data through the resolve
property, and pass in static values through the data
property. The route instance can set children routes (e.g. there are number of children routes for /admin
, /admin/-create-user
, /admin/update-user/:id
, etc).
Guards and resolvers are explained in more detail below. They are capable of achieving a lot of similar things, but have distinct use cases.
The router redirect
property helps prevent routing to an in-between state. For example, the AdminRoutingModule
has a bare ''
route (explained below), but it also redirects to /admin/user-list
, because /admin
is not meant to be a destination on its own. When used in conjunction with the pathMatch
property, this can help create a smooth user experience.
The router data
property can set static values associated with a route. The boilerplate uses this regularly to set page titles for each route. These are consumed in the browser page title, and the global <h1>
. These values are static and generated at compile-time.
Routing can have fallback values. The wildcard fallback value **
, is associated with the NotFoundComponent
.
While Angular can juggle multiple router adjacent outlets, the boilerplate follows the pattern having one router outlet flow. The AppComponent
has the global router outlet that all routed content uses. The root AppComponent
router outlet contains all children lazy loaded router outlets. Each lazy loaded module also has a router outlet for its specific routed content. These router outlets live in wrapping layout components (e.g. the AdminModule
has an AdminLayoutComponent
). This makes it easy to add styling for an entire outlet, or set up content that should be visible regardless of route. The FooterComponent
is invoked in the AppComponent
router outlet, for example. The boilerplate is set up to use the layout components for the empty routes for each feature module (i.e. the ''
route for AdminRoutingModule
has AdminLayout
for its component). This pattern, plus setting up redirect routes so that hitting something like ''
in AdminRoutingModule
redirects to user-list
instead, makes for powerful and extendible routing.
Angular uses route guards to determine if navigation to or from a route can occur. The boilerplate uses the canActivate
and canActivateChild
guards regularly. The feature modules have both canActivate
and canActivateChild
guards set to ensure consistent access across the entire routed module. The advantage of setting route guards for all children is that any new routes that are added will have the guarding automatically applied.
The main use case for route guards in the boilerplate is checking for specific user roles. They prevent routing if the user does not have the correct permissions. These guards take a somewhat trusting approach. The API should be the true judge of whether a user has permission to do something.
Children routes that have guards coming from parents can also have additional guards set specifically, if a more granular approach is necessary.
Guards are triggered before resolvers in the router life cycle. The router checks if something can happen before it tries to potentially pre-fetch data.
Guards can also have side effects. If the check that the guard performs fails, the boilerplate commonly redirects and sends the user a message through a notification.
Guards shouldn't subscribe to observable streams. They should use operators with pipe
, and mark the stream as complete with take(1)
if necessary.
Resolvers are the Angular router's way of pre-fetching data before navigating to a component. Pre-fetching data can be a useful strategy, especially for reusing a detail view component for different routes like create
vs update
. Post-fetching (getting the data after the component has been loaded) is still very common and valid, and is also used in the boilerplate. The router configuration for resolvers links the resolved data to a variable. Any component associated with the route can consume the data variable through ActivatedRoute
.
Resolvers can also have side effects. If the call to fetch data fails, the boilerplate commonly sends the user a message through a notification.
Resolvers shouldn't subscribe to observable streams. They should use operators with pipe
, and mark the stream as complete with take(1)
if necessary. The component is the real consumer of the stream and the component should subscribe.
Components in Angular are the main structure for setting up UI. Components shouldn't be in charge of a lot of business logic. They should know how to get things, send things, and trigger things (like navigation or opening a modal).
There are two ways to display components in Angular, through routing and through selectors. Routed components are loaded and displayed when the Angular router matches the path associated with the component. Selectors are put in the HTML templates like HTML elements. Generally, components associated with selectors are meant to be more reusable than routed components (the dynamic form component has a selector, and is not a routed component). Components can have selectors and be routed, but generally one use case or the other fits better.
The component lifecycle is explained in more detail here. The component lifecycle gives the developer a way to trigger actions in certain parts of the component lifecycle and during change detection. The major events are OnInit
, and OnDestroy
. OnInit
triggers after a component has loaded, and triggers every time it loads. This is different from values initialized in the component constructor, which both happen as soon as the component is initialized, and only once. OnInit
is a good lifecycle to use for behaviors that should occur once the dependencies have been injected, like making a service call to fetch data. OnDestroy
happens when the component is destroyed, most commonly from routing away. OnDestroy
is the best place to put any garbage collection behavior for the component.
A common pattern in the boilerplate for CRUD flow is to have list components and detail components. List components display lists and actions to interact with the list or individual list members. Detail components are where the detailed actions for individual list members actually occur (delete actions usually occur through modals called by the list components). The advantage of this pattern is being able to reuse detail components for create/update actions, and list views wrap tables with any UI that doesn't belong directly to a table.
Angular's default change detection strategy is very eager and expensive. It is easy to use in simple use cases, but does not scale well and can cause performance problems in larger and more complicated applications. There is another change detection strategy, OnPush
. Using this makes change detection much more manual. The component listens to fewer events. The boilerplate leverages OnPush
for every component. This helps hugely with performance. However, OnPush
change detection requires more planning than the default. Not setting up for OnPush
can result in the application not knowing it should update. Generally, the best way to deal with this is to think about where behavior should happen. There is a tool, ChangeDetectorRef
, that can trigger manually a change detection cycle. It's recommended to use it sparingly, because the best and most performant way to work with OnPush
is to design data flow so that it automatically triggers the OnPush
change detection.
Using OnPush
well eliminates the dreaded and hard to understand ExpressionChangedAfterItHasBeenCheckedError
, and greatly removes the need for setTimeout()
or the expensive ngOnChanges
(the boilerplate doesn't need to use either).
This is a wonderful article that goes into great detail about OnPush
change detection, with visuals and demos to show the performance benefits.
The container/presenter pattern, explained below, helps with managing OnPush change detection.
This is a design pattern in the front end world to help separate state, persistence, and presentation concerns in UI. Here is a great article explaining this pattern.
One design consideration that comes up when using this pattern is how to not overdo it. Sometimes a value could be passed many layers deep between parent and child components. In these cases, it might be better to get the value from state instead. (The convention in the boilerplate is to not go more than two layers deep with inputs/outputs. Examples of this can be found in the ListSmartComponent
> ListPresentationComponent
> TableComponent
pattern).
The container component is in charge of assigning values from their sources (getting values from a resolver or a service), sending values out (passing request payloads to setters), and triggering other UI interactions (navigating to new routes, opening modals, etc).The only HTML template a container should have is the selector reference to the presenter component (its own or a generic shared component). It also doesn't need its own style sheets.
Often in Angular the data source is an Observable. Containers should pass raw values to presenters (using the async
pipe if possible). The async pipe can be set up in the input binding call:
<app-test
(test)="(test$ | async)"
></app-test>
The HTML template lives in the presenter component. Presenter components get values through input bindings, and emit values back to parent components through output bindings. Presenters fire events back up to parent container components, with or without values, depending on the event. They pass messages along, they don't enact behavior. Presenters can have simple presentation-only logic (e.g. having a small boolean method that determines if wording should be singular or plural). Presenters shouldn't know about RxJS and should get the raw data (they will update through the input bindings whenever the parent updates).
Some components can be presenter-only. It makes sense to make these shared, and perform reusable UI on generic inputs and outputs.
Components are the primary place to consume data streams in Angular. The boilerplate uses a few patterns to make this easier to manage.
-
async
pipe- Data that is retrieved purely to display is rendered with the
async
pipe. Theasync
pipe manages the Observable automatically.
- Data that is retrieved purely to display is rendered with the
- Manually subscribing
- Sometimes the developer needs the value from an Observable stream within the code of the component, not just the template. In these cases, the stream can be manually subscribed to, assigning the value from the stream to a class property.
- These are two different strategies to consume RxJS data - don't mix them together. If a use case starts out where
async
will work, and then it changes to where manually subscribing is required, remove theasync
implementation for that value.
- Manually unsubscribing
- Manually unsubscribing is unnecessary when using the
async
pipe, which is one of the reasons to use it. - It's also not necessary to manually unsubscribe from data that originally comes from
HttpClient
through an API, or data that comes in fromActivatedRoute
. - It IS necessary to unsubscribe from state observables, reactive form observables, and any observables created manually in the component (which is not a good place to do this)!
- The boilerplate follows the pattern of adding subscription instances to an array, and looping through that array and unsubscribing in the
OnDestroy
lifecycle hook. (There is another pattern that uses thetakeUntil
operator, but usingtakeUntil
causes the side effect of making the stream as complete). - If in doubt, manually unsubscribing is not expensive, while forgetting to do so when needed can cause weird bugs.
- Manually unsubscribing is unnecessary when using the