DEV Community

Cover image for Level Up Your Angular Code: A Transformative User Profile Editor Project (Part 1)
Cezar Pleșcan
Cezar Pleșcan

Posted on

Level Up Your Angular Code: A Transformative User Profile Editor Project (Part 1)

Introduction

In this article series I want to walk you through a journey of code evolution within a practical user profile editor application. I will transform and optimize a simple Angular implementation into a well structured, maintainable solution using advanced techniques.

If you've ever felt overwhelmed by growing code complexity, this series is for you! I'll guide you step-by-step, demonstrating how to tackle common challenges through state management, imperative programming, reactive programming, SOLID principles, Observables, NgRx component-store, NgRx global store, signals, error handling, uploading files, form validation, custom form control, HTTP interceptors, local web server with node.js, directives, and many more. I'll start with the basics and progressively add layers of sophistication and discuss their tradeoffs. You will be able to better understand when and why to choose the proper solution for a specific context.

The Problem

The task at hand is to create a user profile editor, similar to the one in the image below. The editor will include a form with four fields: Name, Email, Address, and Avatar.

User profile editor form

Requirements

From the end-user perspective, I want to be able to:

  • change any of the fields (name, email, address)
  • change the avatar image

For an improved user experience, I'll add some functional requirements:

  • display any validation error messages (e.g. “Please enter a valid email address”)
  • disable the Save button if there are no changes or while the saving request is in progress
  • provide a button for resetting the changes
  • display a progress indicator for upload status (when changing the avatar).
  • display a loading indicator while the user data is retrieved from the server.

Other improvements may come later on the way.

Implementation

Project set up

Begin by cloning the GitHub repository and checkout the 00.base branch. The project uses Angular v17.3.3.

Then run yarn install and ng serve. Currently there is nothing implemented so far, but this is what we'll be doing next.

Note: Yarn is a replacement for npm. It can be installed with npm install -g yarn.

Planning

Before actually writing code, we need some planning. It might be tempting to jump right into the editor and start writing code. However, I consider it essential to take a step back and think about the overall structure of the application.

In this planning phase I want to focus on the following:

  • What is the end result, what will the user see on the page?
  • What are the main tasks that need to be completed?
  • In what order should these tasks be done?

The high-level tasks I would define are, in order of their priority:

  • create the form layout (just displaying the necessary fields, without any data handling)
  • read user data from an API and populate the form
  • handle server errors
  • updating the user data
  • uploading a new avatar

Once we have a good understanding of the overall plan, we can start to break down the tasks into smaller, more manageable pieces, which will make it easier to write the code and track the progress.

Task 1: Create the form layout

I will use a reactive form with Material components. Open user-profile.component.html file and paste the following content:


Then open user-profile.component.ts and paste:

Your user-profile component code should look like here. I also added some styling for the template.

The form is now in place with some placeholder values. We want to make this realistic and read the data from the backend, which will be covered in the next section.

Task 2: Develop an API endpoint for user data retrieval

To facilitate communication with the backend to read and save data, I'll use a simple node.js express server that simulates a real-world backend. The code needs to reside in a folder named backend within the root project folder. You can checkout the entire code from 02.get-api branch where you'll find the contents of the backend server. The package.json file includes the new packages and scripts for building and running the server. The data is stored locally in the db.json file. You need to run yarn install and then yarn run backend:run to start the server (run these commands from the root project folder). The server starts listening on port 3000, so make sure no other app is using it.

Navigate to http://localhost:3000/users or http://localhost:3000/users/1 and you should see the contents from the db.json file.

Now that we have the get endpoint API in place, let's read the data from our component.

Task 3: Read user data and populate the form

First of all I'll jump to our component class and declare my intention to read the user data when the component is initialized.

Note: As a general practice, I prefer to use descriptive functions or methods for what I have to to, and then to describe how to implement them. This approach is like starting with building a skeleton of a system, along with the high-level workflow, and after that to actually implement how each operation is executed. Working this way has been helping me to implement complex systems and I consider it paramount in designing efficient software.

Now let's describe what loadUserData() should do:

  • make an http get request to the API.
  • when the response is ready, to populate the form with the user data.

For simplicity, I'll write the entire necessary code in the component class, without creating an additional service for getting user data. Of course, this is not a good practice for a production code, but I want to make small steps and gradually improve the code.

I had to create some interfaces for our data:

The pick function is from the lodash library, so you need to install some packages: yarn add lodash-es, yarn add --dev @types/lodash-es.

You also need to inject the HttpClient service into the component class and to declare its provider in the app.config.ts file:

For now I assume a successful API response, and later we'll implement the error handling.

Having the server started, reload the page. Your code should now be similar to the one on the 03.get-user-data branch.

Task 4. Display a loading indicator

We have the user data displayed, which is a good progress. I want to improve the user experience by showing a loading indicator until the request is completed.

For this I need two pieces of information:

  • a flag that indicates that the load request is in progress,
  • a visual representation of the loading state.

I will implement the flag as a boolean class property isLoadRequestInProgress and update its value right before the request is initiated and after it completes:

At this moment we know when the load request is in progress and we have to update the template based on this state. There could be different ways to visualize the loading progress (like a spinner animation), but I will choose the simplest one, that is, to hide the entire form and show a message.

Note: My intention with this tutorial is to provide the mechanisms to handle different scenarios, upon which visually appealing content could be created in collaboration with the UX team. From a software design perspective, I achieve the separation of concerns: I just have the information about the loading status, but I'm not concerned about how the view will look like. This separation enhances extensibility, someone could want later to change the view, but the logic of how the loading status is computed will be unaffected by the change request.

As you may notice, I'm using the @if new control syntax from Angular v17. It does the same as the classical *ngIf directive:

Because you're serving the application form your localhost you may not notice the loading message. You could simulate a slower connection from the Network tab in DevTools window.

Your code should now be similar to the one on the 04.loading-indicator branch.

Task 5. Handling loading errors

In the real world we could encounter errors when making an HTTP request, for whatever reasons. This is something that we must handle and not assume that errors don't happen.

What do we need in order to handle any errors from the loading request?

  • to know when an error occurs
  • to inform the user about the error
  • to offer the user the ability to retry the request

Catching the error

I'll use the catchError RxJS operator (see RxJS docs) and for simplicity I'll just set a flag hasLoadingError that indicates the presence of an error.

Note: As I've mentioned earlier, I want to provide only the mechanism of signaling the error, without considering any other details. In a real-world scenario the errors could have various sources, like no network connection, internal server error, unauthorized access, and so on. Handling these errors is a different topic not covered by this tutorial.

Here is the updated loadUserData() method:

Note: I must return an observable from the catchError callback, so I'm using the EMPTY observable (see RxJS docs), otherwise, if I return a non-empty observable, the subscriber's next method will be invoked with the returned observable value.

I've also used the finalize operator (see RxJS docs), which has its callback executed when the request observable terminates on complete or error, to reset the isLoadRequestInProgress flag.

Inform the user about the error and retry the operation

Now that we have the information if an error occurred, we have to update the template to display a simple message. Additionally we can add a Retry button to reload the data. In a real-world application there could be different use cases, but I choose a simple scenario for now.

To simulate an error there are some options:

  • to modify the URL in the getUserData$() method
  • to stop the backend server
  • to simulate an offline network connection from DevTools

I consider stopping the backend server the most convenient option. Here is my scenario you could follow:

  • stop the backend server
  • reload the app in the browser: you should see the error message
  • click Retry: the loading message should be visible and then the error (you can eventually throttle down the network connection)
  • restart the backend server and click Retry: the form should be now displayed with the expected user data

At this stage you should have the code similar to the one on the 05.loading-errors branch.

Task 6. Saving the user data

One of the main features of this tutorial is the ability to update the user profile data. For this we need a data persistent storage which is provided by the backend server. At this stage I'll focus on updating the name, email and address, without the profile avatar, which I'll cover in a separate task. I'll implement error handling in a later step, but for now, I'll assume successful save operations.

The implementation for this features requires two parts:

  • sending the data to the server
  • saving the data on the server-side

Sending the data to the server

This action is triggered when the form is submitted, so I need to define the ngSubmit handler to make a PUT request to the server with the current form value. To keep the things simple, I leave aside any form validation for now.

Update the user-profile.component.html template:

and the user-profile.component.ts component class:

Updating the data on the server

Add the following content to your server.ts file:

Note: This is just a simple implementation to save the user data and shouldn't be used in a production code.

After making these changes restart the backend server, reload the browser, and make any changes in the form (keep in mind that there are no validations in place). After you submit the form reload the page and the changes should be persistent.

At this stage you should have the code similar to the one on the 06.save-user-data branch.

Task 7. Validate user data and display the validation errors

The user data need to have some constraints to be saved in the database, like:

  • the name should not be empty and it should be unique
  • the email should have a valid format
  • the address should not be empty

Note: these are just some simple validation rules to illustrate how to work with them.

As a general best practice, the server should always validate the data it receives, regardless on any client-side validations. For simplicity, our server will validate only if the user's name is unique, leaving the other validation rules on the client-side.

Client-side validation and error messages

We already have some validators in place for the form fields (see the form property definition in the UserProfileComponent class). What we need is to display the validation error messages in the template:

I've added the <mat-error> element for displaying the validation errors, which is automatically shown by Angular Reactive Forms only when the corresponding field has errors.

Server-side validation

As mentioned above, I'll validate on the server-side only if the user's name is unique:

In order for the validation to fail, you need to add a new user in the db.json file.

As a convention, the server responds with 400 HTTP status code, and with a body containing the validation errors.

Display server-side validation errors

In the case of client-side validation, the errors were implicitly set by the Angular Reactive Form, but for the server-side validation errors we need to manually handle them. Update the saveUserData() method from the user-profile.component.ts file:

The template should be updated too:

Disable form submission

You may have noticed that even if there are client-side validation errors, the form can still be submitted. To avoid this we can simply disable the submit button. The form will internally detect the status of the submit button and even if we hit the ENTER key in any of the fields, the form won't be submitted.


At this stage you should have the code similar to the one on the 07.validate-user-data branch.

Task 8. Add some UX improvements

  • add a Reset button
  • disable the Save button while the save request is in progress or the form is pristine
  • add a notification when the saving successfully completes

Reset the form

This feature is useful when we want to reset the form to the last saved state.
These are the changes I've made:

  • created a new Reset button
  • defined the restoreForm() method
  • defined the disabled state of the button
  • determine is the form value is changed since the last save
  • disable the Save button if there are no changes

Disable the Save button

One additional condition can be used to disable the Save button: while the save request is in progress. I also want to disable the form controls during this state.

Add a notification

After a successful save I want to display a notification. I'll use the Material snackbar component for this.


At this stage you should have the code similar to the one on the 08.ux-improvements branch.

Task 9. Handle unexpected saving errors

Earlier I've described how to handle validation errors, which is something that we can expect to happen. But what about other errors, like internal server error, or no network connection error. If any of such errors happen, I want to simply display a notification to the user. I'll just use the NotificationService for this, which can eventually be customized to use different colors for the snackbar, but this is out of scope for now.
Let's update the catchError callback in the saveUserData() method:

We need to simulate some errors to see how the error handling works. Here are some possible options to try:

  • simulate no network connection from the browser DevTools,
  • stop the backend server,
  • programatically send an error from the server.

The code below shows how to generate a server error. Just for demo purposes, if we enter 'error' in the name field, the server will send a 500 error:

Task 10. Uploading an avatar image

Here comes a very nice feature to implement: uploading and displaying an image. The entire process requires some steps:

  • select an image to upload
  • display the uploaded image
  • store the image on the server
  • generate a URL for it

Select an image and display it

The straightforward implementation is to use the <input type="file" /> element for selecting an image from the local computer.

In the template, we'll have to update 2 parts:

  • the source of the image
  • the button to upload an image

The onImageSelected method is called every time the user selects a new image. We need to display that image in place of the existing avatar. An image needs an URL, which can be an external or local resource. I don't want to upload the image right after selecting it, because the user could maybe change their mind, so I'll use URL.createObjectURL() function that will generate a local URL based on the file content.

I want to add a fix here: after I select an image and then click the Reset button, the avatar is indeed restored, but the name of the previously selected file is still displayed, and it should be reset too. For this I'll just write an empty value to the input element:

Upload the image to the server

The standard and recommended way to send files to the server is using the FormData browser API. The object created with this constructor will be sent as a payload to the saving request. As a consequence, the values of the other 3 form fields (name, email, address) will be part of this FormData object.

Let's see how we can construct this object. Instead of directly sending the form values, we'll create a FormData object that holds the form values and replace the avatar with the selected image, if any.

Once the client-side is set up, we have to upgrade the backend server to support uploading files. An implementation option is to use the multer package. First of all, we have to install the additional packages as dev dependencies:
yarn add -D multer @types/multer.

Then open your server.ts file and add the following contents to set up the library configuration (it's recommended to be added at the beginning of the file):

Note: There are some additional things to do:

  • create the images folder inside backend
  • add the contents of that folder to .gitignore: /backend/images/*

Now we need to run the uploading process and return the generated filename. For this we have to update the /users/:id route:

Make sure to restart the backend server and then go to the app and upload an image. If you refresh the page, the image is not displayed anymore, because its URL points to the local Angular web server, instead of the server URL. Let's fix this.

In the template I'll use a method to compute the image URL:


Try to upload again an image and save the profile. You may notice that the Save and Reset buttons are still enabled, but they should be disabled. Why is this happening? Because the form is not computed as pristine due to the avatar value holding the local URL to the selected file. To solve this, we have to reset the form after the saving completes successfully:

At this stage you should have the code similar to the one on the 10.avatar-upload branch.

Task 11. Upload progress indicator

A nice feature I want to have is an indicator for the upload progress. For the visual representation I pick the MatProgressBar component from the Angular Material library. Let's explore how the compute the percentage value of the progress bar.

Getting access to upload progress information

It is made available by setting reportProgress and observe options of the request:


Now the return type of the saveUserData$ method is
Observable<HttpEvent<UserDataResponse>>, where before it was just Observable<UserDataResponse>. We'll make the necessary adjustments soon.

Visual representation

Add the following snippet right before the closing </form> tag:


The uploadProgress variable will be described in the next paragraph.

Computing the upload progress percentage

We need to update the observer function of the saveUserData$() observable in the saveUserData() method:

Let's see it in action! Select an image having around 1MB and throttle down the network connection from the DevTools. You should see how the progress bar grows and then it hides.

You should be in sync with the code from 11.upload-progress branch.

Outro

We've covered up the requirements from the beginning of the tutorial and we've made a good progress so far. While functional, the current implementation prioritized getting the features working, without discussing about any software design best practices. I've just wanted to have a starting point for the following articles that will be about improving this simple version by focusing on scalability and extensibility, which are the foundation of Angular enterprise applications.

To manage something big we have to be able to control small things first, every oak grows from a little seed, and so is our expertise: start small, add little by little continuously, and the result will get bigger and bigger.

In the next article I'll focus on refactoring the existing code by following the SOLID principles and by covering the implementation of: custom form control for image management, error handling in HTTP interceptors, custom RxJs operators, directive for displaying the form field errors.

Top comments (0)