DEV Community

Cover image for Enhancing Angular Reactive Forms with a Custom Image Uploader FormControl
Cezar Pleșcan
Cezar Pleșcan

Posted on • Edited on

2

Enhancing Angular Reactive Forms with a Custom Image Uploader FormControl

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:

protected form = inject(FormBuilder).group({
name: new FormControl('', [Validators.required]),
email: new FormControl('', [Validators.required, Validators.email]),
address: new FormControl('', [Validators.required]),
avatar: new FormControl<string|Blob>('')
});
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.
<input matInput formControlName="name" type="text" />
<input matInput formControlName="email" type="text" />
<input matInput formControlName="address" type="text" />
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.
<div class="avatar-container">
<img [src]="getAvatarFullUrl()" alt="avatar"/>
<input #fileInput
type="file"
accept="image/*"
(change)="onImageSelected($event)"
/>
</div>
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 in the previous article? 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.

    <img [src]="getAvatarFullUrl()" alt="avatar"/>
    <input #fileInput
    type="file"
    accept="image/*"
    (change)="onImageSelected($event)"
    />
    @Component({
    selector: 'app-image-form-control',
    standalone: true,
    imports: [],
    templateUrl: './image-form-control.component.html',
    styleUrl: './image-form-control.component.css'
    })
    export class ImageFormControlComponent {
    protected onImageSelected(event: Event) {
    const file = (event.target as HTMLInputElement).files?.[0];
    if (file) {
    this.form.controls.avatar.setValue(URL.createObjectURL(file));
    }
    }
    protected getAvatarFullUrl() {
    if (isString(this.form.controls.avatar.value)
    && this.form.controls.avatar.value
    && !this.form.controls.avatar.value.startsWith('blob:http')) {
    return 'http://localhost:3000/images/' + this.form.controls.avatar.value;
    }
    return this.form.controls.avatar.value || '/assets/avatar-placeholder.jpg';
    }
    }
  3. Integrate the component: Now, I'll update the form template in user-profile.component.html to use the ImageFormControlComponent:

    <div class="avatar-container">
    <app-image-form-control formControlName="avatar"></app-image-form-control>
    </div>

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 this component to interact with the reactive form through the formControlName directive. 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:

<div class="avatar-container">
<app-image-form-control formControlName="avatar"></app-image-form-control>
</div>
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 and suppose that the image source comes from an imgSrc property:

@if (imgSrc) {
<img [src]="imgSrc" alt="avatar"/>
}
@else {
<span>No avatar</span>
}
Now I need to define how the imgSrc property is set in the 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 of the interface (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
    @Component({
    selector: 'app-image-form-control',
    standalone: true,
    imports: [],
    templateUrl: './image-form-control.component.html',
    styleUrl: './image-form-control.component.css',
    // Register this component as a ControlValueAccessor
    providers: [
    {
    provide: NG_VALUE_ACCESSOR,
    useExisting: ImageFormControlComponent,
    multi: true
    }
    ]
    })
    export class ImageFormControlComponent implements ControlValueAccessor {
    // The source URL for the displayed image
    protected imgSrc?: string;
    registerOnChange(fn: any): void {
    }
    registerOnTouched(fn: any): void {
    }
    setDisabledState(isDisabled: boolean): void {
    }
    /**
    * Writes a new value from the form model into the view.
    * In this case, it sets the imgSrc based on the provided value (image filename).
    * Required by the ControlValueAccessor interface.
    */
    writeValue(value: string): void {
    this.imgSrc = value ? `http://localhost:3000/images/${value}` : ''
    }
    }
    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 the 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:

@if (imgSrc) {
<img [src]="imgSrc" alt="avatar" width="100%"/>
}
@else {
<span>No avatar</span>
}
<input type="file"
accept="image/*"
(change)="onImageSelected($event)"
/>
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:
/**
* Handles the file selection event when the user chooses an image.
* @param event - The change event triggered by the file input element.
*/
protected onImageSelected(event: Event) {
// Retrieve the selected file from the input element
let file = (event.target as HTMLInputElement).files?.[0];
if (file) {
// Create a temporary URL for the selected file and set it as the image source
this.imgSrc = URL.createObjectURL(file);
}
}
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:

/**
* Handles the file selection event when the user chooses an image.
* @param event - The change event triggered by the file input element.
*/
protected onImageSelected(event: Event) {
// Retrieve the selected file from the input element
let file = (event.target as HTMLInputElement).files?.[0];
if (file) {
// Clean up previous blob URL if it exists.
this.revokeImageURL();
// Generate a temporary URL for the selected image and update the image source.
this.imgSrc = this.generateImageURL(file);
}
}
/**
* Angular lifecycle hook called when the component is destroyed.
*/
ngOnDestroy() {
// Ensures any existing blob URLs are revoked to prevent memory leaks.
this.revokeImageURL()
}
/**
* Revokes the object URL associated with the image source if it's a blob URL.
*/
private revokeImageURL() {
if (this.imgSrc && this.imgSrc.startsWith('blob:')) {
URL.revokeObjectURL(this.imgSrc);
// Reset the imgSrc because it points to an invalid URL
this.imgSrc = undefined;
}
}
/**
* Generates a temporary blob URL for the given file.
* @param file - The File object to generate a URL for.
* @returns A blob URL string that can be used to display the file in the browser.
*/
private generateImageURL(file: File): string {
return URL.createObjectURL(file)
}
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:

export class ImageFormControlComponent implements ControlValueAccessor, OnDestroy {
// The source URL for the displayed image
protected imgSrc?: string;
// A function to notify the form of value changes (set by Angular)
protected onChange: Function | undefined;
/**
* Obtains a reference to the file input element using ViewChild.
* This reference is used to clear the input's value after an image is selected or written.
* The {static: true} option ensures the reference is available during initialization.
*/
@ViewChild('fileInput', {static: true})
protected fileInput!: ElementRef<HTMLInputElement>;
/**
* Registers a callback function (fn) that should be called when the control's value changes in the UI.
* This function is provided by Angular forms API and is essential for two-way binding.
*/
registerOnChange(fn: Function): void {
this.onChange = fn;
}
// the rest of the code...
/**
* Writes a new value from the form model into the view.
* In this case, it sets the imgSrc based on the provided value (image filename).
* Required by the ControlValueAccessor interface.
*/
writeValue(value: string): void {
this.imgSrc = value ? `http://localhost:3000/images/${value}` : '';
// Clear the file input element's value after setting the image source
this.fileInput.nativeElement.value = '';
}
/**
* Handles the file selection event when the user chooses an image.
* @param event - The change event triggered by the file input element.
*/
protected onImageSelected(event: Event) {
// Retrieve the selected file from the input element
let file = (event.target as HTMLInputElement).files?.[0];
if (file) {
// Clean up previous blob URL if it exists.
this.revokeImageURL();
// Generate a temporary URL for the selected image and update the image source.
this.imgSrc = this.generateImageURL(file);
// Notify the form control that the value has changed.
// This triggers validation and updates the form's state.
this.onChange?.(file);
}
}
// the rest of the code...
}
I've also added a template reference variable for the <input> element, which is used in the component as a ViewChild:
<input #fileInput
type="file"
accept="image/*"
(change)="onImageSelected($event)"
/>
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:
export class UserProfileComponent implements OnInit {
// ... the rest of the code
protected restoreForm() {
this.userData && this.updateForm(this.userData);
}
private getFormData() {
const formData = new FormData();
Object.entries(this.form.value).forEach(([fieldName, value]) => {
formData.append(fieldName, value as string | Blob);
})
return formData;
}
}

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:

// A function to notify the form of value changes (set by Angular)
protected onChange: Function | undefined;
/**
* Registers a callback function (fn) that should be called when the control's value changes in the UI.
* This function is provided by Angular forms API and is essential for two-way binding.
*/
registerOnChange(fn: Function): void {
this.onChange = fn;
}
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:

@if (imgSrc) {
<img [src]="imgSrc" alt="avatar" width="100%"/>
}
@else {
<span class="no-avatar">No avatar<br/>Choose an image to upload</span>
}
<input #fileInput
hidden
type="file"
accept="image/*"
(change)="onImageSelected($event)"
/>
<button class="upload-button"
mat-mini-fab
color="primary"
(click)="fileInput.click()"
type="button"
>
<mat-icon>attach_file</mat-icon>
</button>
I've added some styling to the component elements:
:host {
position: relative;
display: block;
}
.upload-button {
position: absolute;
top: 0;
right: 0;
width: 32px;
height: 32px;
mat-icon {
font-size: 20px;
height: 20px;
}
}
.no-avatar {
margin-right: 46px;
}
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:

// Helper function to generate the full avatar URL
function getAvatarUrl(req: express.Request, avatarFilename: string | undefined): string {
return avatarFilename
? `${req.protocol}://${req.get('host')}/images/${avatarFilename}`
: '';
}
// Returns a new user object with the avatarUrl added
function buildUserDataResponse(req: express.Request, user: User): User {
const avatar = getAvatarUrl(req, user.avatar);
return { ...user, avatar };
}
// Get a specific User
app.get('/users/:id', (req, res) => {
// existing code ...
if (!user) {
// existing code ...
}
else {
res.json({
status: 'ok',
data: buildUserDataResponse(req, user)
});
}
});
// Update User
app.put('/users/:id', upload.single('avatar'), (req, res) => {
// existing code ...
res.send({
status: 'ok',
data: buildUserDataResponse(req, updatedUserData)
});
});
view raw server.ts hosted with ❤ by GitHub
With this change, the writeValue method in the component becomes simpler:
writeValue(value: string): void {
this.imgSrc = value;
// Clear the file input element's value after setting the image source
this.fileInput.nativeElement.value = '';
}
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.

Image of Timescale

🚀 pgai Vectorizer: SQLAlchemy and LiteLLM Make Vector Search Simple

We built pgai Vectorizer to simplify embedding management for AI applications—without needing a separate database or complex infrastructure. Since launch, developers have created over 3,000 vectorizers on Timescale Cloud, with many more self-hosted.

Read full post →

Top comments (0)

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more