DEV Community

Cover image for Refactoring: Image Upload Form Control
Cezar Pleșcan
Cezar Pleșcan

Posted on

Refactoring: Image Upload Form Control

Introduction

In this article, I'll guide you through building a special component that makes uploading images in forms much easier. This component won't just make the form templates look cleaner, it'll also work seamlessly with the existing reactive forms setup, letting you treat image uploads just like any other form field.

Throughout this article, I'll walk you through the following steps:

  • Creating the component: Generate the new component and migrate the existing code.
  • Displaying the saved image: Implement the logic to display the currently saved image.
  • Selecting a new image: Add functionality to allow users to choose and preview a new image before uploading it.
  • Form integration: Integrate the custom component with Angular reactive forms system.
  • Removing hardcoded paths: Refactor the code to avoid hardcoding image URLs and instead fetch them from the server.
  • Custom upload button: Create a custom upload button for a consistent look across browsers.

A quick note: Before we begin, I'd like to remind you that this article builds on concepts and code introduced in previous articles of this series. If you're new here, I highly recommend that you check out those articles first to get up to speed. You can find the starting point for the code I'll be working with in the 13.validation-error-directive branch of the https://github.com/cezar-plescan/user-profile-editor/tree/13.validation-error-directive repository.

Identifying the current issues

Let's take a look at our form in the user-profile.component.ts file, which includes controls for name, email, address and avatar:

When I examine the template in user-profile.component.html I can easily identify the input elements associated with the first 3 controls, thanks to the formControlName directive provided by Angular's Reactive Forms Module.
These input elements provide both the display and modification of the form control value. However, when it comes to images, my current approach separates these functionalities.
I rely on an <img> tag to display the existing image, but for uploading a new image, I use a separate <input type="file"/> element. This fragmented approach makes the code less maintainable and creates an unintuitive user experience. From my point of view, users should have a clear and consistent way to interact with an image in a form, whether they're viewing the current image or selecting a new one for upload.

To address this, I'll create a dedicated Angular component to manage both image display and upload. I want this component to be able to receive the formControlName directive, just like any other form control.

But why do I create a component instead of a directive, like I did for the validation errors? While directives are excellent for manipulating existing elements, a component provides both a view (for displaying the image and selecting a new one for uploading) and its associated logic.

By extracting this functionality into a reusable component, we'll achieve some advantages:

  • Simplified templates: our form templates become cleaner and more focused on the overall structure.
  • Single Responsibility Principle: the component adheres to SRP, as it encapsulates all the logic and behavior related to image handling, keeping the parent component concerns separate.
  • Reusability: we can easily use this component in any form throughout our application.

Let's explore the steps involved in creating this dedicated image form control component.

Implementing the new component

Create the new component files

  1. Generate the component: I begin by generating a component named image-form-control within the src/app/shared/components folder, using the Angular CLI: ng generate component image-form-control.

  2. Migrate Existing Code: Next, I'll transfer the relevant code from the form template user-profile.component.html into image-form-control.component.html. Similarly, I'll move the associated methods (getAvatarFullUrl and onImageSelected) from the user-profile.component.ts component into image-form-control.component.ts. Remember to remove these methods from the UserProfileComponent class.

  3. Integrate the component: Now, I'll update the form template in user-profile.component.html to use the ImageFormControlComponent:

At this stage the application is broken. We still have some work to do to make the new component fully functional.

The first visible error is that the form property doesn't exist in the ImageFormControlComponent. Additionally, we need to instruct the component to interact with the reactive form. In the following sections I'll describe how to resolve these challenges and make the component a fully functional form control.

I'll break down the implementation into two main parts:

  • displaying the saved image
  • selecting a new image to upload

Note: This new component is essentially a custom form control. If you're new to this topic, I highly recommend you to refer to this comprehensive guide from the Angular University blog. My implementation will simply follow the steps from this guide.

Displaying the image

Let's take a closer look at how our component is used in the form template:

We notice that it only has one input property, formControlName="avatar", without any direct reference to the form or the avatar form control itself. Stay with me to explore how to connect our component to the Reactive Forms setup.

For now, I'll just use the <img> tag in our template:

Now I need to define how the imgSrc property is set in ImageFormControlComponent class. Following the Angular custom form controls guide, I need to perform several steps:
  1. declare the imgSrc property in the component class
  2. the component class should implement the ControlValueAccessor interface; this helps to communicate with the reactive forms system
  3. implement the writeValue method (I'll ignore the other methods for now); this method will be called by Angular to set the initial image source when the form loads
  4. register the component in the dependency Injection system; this tells Angular that this component should act like a form control
    Now, our component can read the avatar value from the form and display the image.

Note: The writeValue method contains a hardcoded string for the images path. This is not ideal and I'll address it in a later section.

To understand how our custom form control integrates with Angular reactive forms, let's dive a bit deeper into the internals.

NG_VALUE_ACCESSOR injection token

Let's see why we need this token and how it is used internally. I'll jump straight into the formControlName directive source code. The relevant line is in the constructor definition:
@Optional() @Self() @Inject(NG_VALUE_ACCESSOR) valueAccessors: ControlValueAccessor[]

What does this actually mean?

NG_VALUE_ACCESSOR is an injection token provided by Angular. Its purpose is to act as a lookup key for finding the appropriate ControlValueAccessor implementation for a given form control. In the component providers array, we're essentially saying, "Hey Angular, when you encounter a form control that needs a ControlValueAccessor, use my ImageFormControlComponent as the implementation." When Angular processes the formControlName directive, it looks for a NG_VALUE_ACCESSOR provider in the injector hierarchy. Since we've registered our component as the provider, Angular knows how to use the component methods (writeValue, registerOnChange, etc.) to interact with the form control.

The formControlName directive, when applied to an element (in our case, the app-image-form-control component), uses Angular dependency injection system to look for providers of the NG_VALUE_ACCESSOR token on that specific element itself. It's not looking for a global provider or one in a parent component. This is indicated by the @Self() decorator on the injection site in the directive's constructor.

When the formControlName directive is applied to our component, it finds this provider and uses it to get an instance of the ControlValueAccessor. Since we've provided our component itself, the directive gets a direct reference to it.

Executing the writeValue method

Here is a simplified process of how the method is called internally:

  1. Form Control Setup: When we create a FormControl in our component (either directly or through FormBuilder) and bind it to your custom form control using formControlName in the template, Angular establishes a connection between them.
  2. Initial Value Setting: If we've provided an initial value to the FormControl (e.g., through the value property or setValue method), Angular will call the writeValue method on our custom form control (the ImageFormControlComponent) to set that initial value.
  3. Value Changes: Whenever the value of the FormControl changes (e.g., through user input, setValue, or patchValue), Angular will again call writeValue on our component to update it with the new value. This ensures that the UI element stays in sync with the form control's value.

Selecting an image to upload

The current template contains only the <img> tag. For selecting an image file to upload I have to add the <input type="file"/> element back into the template:

I've also included the handler onImageSelected for the change event, that will be triggered when the user selects a file. This is necessary to display the selected image in place of the original one, before uploading it to the server. Here is its implementation:
I want to upload the image to the server only when the user explicitly submits the form. Why do I do this? Because the user could change their mind after selecting an image and I don't want to immediately send the image to the server, but only when the user is ready to save the form.

Serving files locally

The selected image file doesn't exist on the server yet and you might wonder where it is served from. The answer lies in the URL.createObjectURL(file) method of the browser's API. This method takes a File object (or a Blob) and generates a URL string. This URL doesn't point to a physical file on our server; instead, it references the file data directly in the browser's memory.

The generated URL is a special type called a blob URL (e.g., blob:http://localhost:4200/d9856eeb-2405-4388-8894-064e56c254a8). We can use this URL as the src attribute of an <img> tag to display the selected image in the browser without needing to upload it to a server first. You can verify this by inspecting the element in DevTools after selecting an image file; you'll notice the src attribute value has a format similar to the example above.

Revoking blob URLs

Blob URLs are temporary. They only exist as long as the document in which they were created remains open. Once the document is closed or navigated away from, the blob URL is automatically revoked by the browser. It's a good practice to release blob URLs when they are no longer needed using URL.revokeObjectURL(). This helps avoid potential memory leaks, especially if you're dealing with large image files. Let's see how to incorporate this method in our component:

To verify that the blob URLs are indeed being discarded, follow these steps:
  • select an image file
  • go to DevTools and open the image source URL in a new tab
  • select another image from the form
  • go to the previously opened tab and refresh it
  • you should no longer be able to view the previous image, confirming that the blob URL has been revoked

Notify the form about the new image selection

At this stage, you might have noticed that the Save and Reset buttons don't become active after selecting a new image. This isn't what we expect. When typing into the text input fields, the buttons become active, and we expect the same behavior when selecting a new image. This is because the form doesn't yet know that we've changed the image.

Let's see how to address this. The reactive forms system provides a way to notify the form control that its value has changed by calling a function that Angular registers with our custom form control. The Angular custom form controls guide provides a detailed explanation of this process.

Here is the updated image-form-control.component.ts file:

I've also added a template reference variable for the <input> element, which is used in the component as a ViewChild:
In the user-profile.component.ts file I've removed all references to fileInput property, as it's now managed entirely by the ImageFormControlComponent; two methods were affected:

Now I'll explain the key changes step by step:

registerOnChange method

Angular calls the registerOnChange method, part of the ControlValueAccessor interface, internally. The only action required here is to store a reference to the internal function that will be used to notify the form control when its value changes:

Now, we need to call this stored function whenever the user selects a new image. In the onImageSelected method I've added this.onChange?.(file) which will internally notify the form control about the change. This results in the form buttons becoming enabled after selecting an image.
Clear the image filename after upload

There's one refinement to make here. Currently, after the form is submitted with a new image, the filename remains displayed in the file input element. To fix this, we need to clear the file input value after the form is submitted and the image is uploaded. To achieve this, we have to modify the writeValue method to also clear the file input.

First, we need access to the file input element in the template. I've attached a template reference variable to the element <input **#fileInput** ... />. Then, to access it in the component I've used the @ViewChild decorator @ViewChild('fileInput', {static: true}) protected fileInput!: ElementRef<HTMLInputElement>.

Finally, to clear the filename, I've simply set its value to an empty string within the writeValue method: this.fileInput.nativeElement.value = ''.

With these changes, the image upload component is now integrated with the reactive form.

Improvements

In this section I'll explore some enhancements we can make to our image form control component, taking its functionality to a new level.

Custom upload button

The current implementation of selecting an image to upload is rendered differently across browsers. There's no way of consistently style a plain input of type file. A solution is to hide the default input element and create a custom button that triggers it behind the scenes.

Here is the updated template:

I've added some styling to the component elements:
Don't forget to import the necessary Angular Material modules MatButtonModule and MatIconModule into the component.

The trick is that the custom button delegates the click event to the hidden file input. With this approach we can fully customize the appearance of the upload button. If necessary, the filename of the selected image can be extracted and displayed, using additional logic in the template and the component, but I've omitted that for simplicity.

Remove hardcoded path for image URLs

In the previous implementation, I've hardcoded the path for image URLs within the component. However, it's not ideal to have the client-side responsible for constructing URLs. Ideally, the server should provide the complete URL for each image.

There are several advantages of this approach:

  • Environment Flexibility: By dynamically generating URLs, our application becomes more adaptable to different environments (development, staging, production) without requiring manual changes to hardcoded paths.
  • Improved Maintainability: Centralizing URL generation on the server makes it easier to manage and update image paths if needed.

To address this, I've updated the server.ts file. When the server sends a response containing the user data, it will include the full URL for the avatar image, while keeping only the filename stored in the db.json file:

With this change, the writeValue method in the component becomes simpler:
Remember to restart the server to see these changes.

Additional Resources

Here are some additional resources that will help you dive deeper into the concepts covered in this article.

Custom form control

File uploading

Custom upload button

Conclusion: Building powerful forms with custom controls

In this article, we've taken a significant step towards mastering Angular forms by creating a reusable ImageFormControlComponent. We've seen how to:

  • Transform a basic image upload element into a full-fledged form control.
  • Integrate it seamlessly with Angular reactive forms system.
  • Handle image display, selection, and preview.
  • Ensure efficient memory management with blob URLs.
  • Address common challenges like cross-browser styling inconsistencies.

This custom component not only improves our code but also enhances the user experience by providing a clear and consistent way to manage image uploads within your forms.

I encourage you to experiment with the code from this article and explore the possibilities for further enhancements. You can find the code for this project at: https://github.com/cezar-plescan/user-profile-editor/tree/14.image-form-control.

If you have any questions, suggestions, or experiences you'd like to share, please leave a comment below! Let's continue the conversation and learn together.

Top comments (0)