International JavaScript Conference https://javascript-conference.com/ Mon, 15 Jun 2026 13:21:59 +0000 en-US hourly 1 https://wordpress.org/?v=6.9.4 https://javascript-conference.com/wp-content/uploads/2017/03/ijs-favicon-64x64.png International JavaScript Conference https://javascript-conference.com/ 32 32 AI, JavaScript, and the End of the Website https://javascript-conference.com/blog/ai-javascript-future-of-web-development/ Mon, 15 Jun 2026 08:57:01 +0000 https://javascript-conference.com/?p=210085 AI is moving rapidly into the web stack. Models run in browsers, agents interact directly with services, and conversational interfaces are challenging traditional navigation. As a result, developers, architects, and UX designers face a broader question: what happens when intelligence becomes a native part of web applications? The answer may change not only how applications are built, but also how users interact with them—and even what we consider a website in the first place.

The post AI, JavaScript, and the End of the Website appeared first on International JavaScript Conference.

]]>
▶ Video Guide: AI, JavaScript, and the End of the Website


Note: This video and podcast was generated using AI, adapting the original content and technical insights created by the author of the iJS blog post.

▶ Podcast Guide: AI, JavaScript, and the End of the Website

Post-AI Shift: What Is Changing?

The foundations of web development remain remarkably stable. Modern web applications are still built with HTML, JavaScript, and CSS. What is changing is not the technology stack itself, but how software is created and who can create it.

Framework expertise remains valuable, but AI increasingly lowers the barrier to entry. Knowledge that once required years of hands-on experience is now embedded in the models developers use every day. With a solid understanding of software engineering principles and effective use of AI tools, developers can work productively across frameworks, languages, and ecosystems that may not be their primary area of expertise.

At the same time, AI is increasing the importance of genuine expertise. Models can generate code, explain APIs, and suggest solutions, but they cannot reliably judge whether a design decision is appropriate, a security risk is acceptable, or an architectural trade-off makes sense in a particular context. The more capable AI becomes, the more valuable expert judgment becomes as the mechanism that validates, challenges, and guides its output.

How AI Is Reshaping Fullstack JavaScript

Fullstack as a discipline won’t go away. It’s gaining an additional dimension. Frontend, backend, and AI each have their own focus, but borders between expertise areas are getting blurrier. Frontend engineers can now deploy applications more easily, implement backend APIs, and cross into areas they previously couldn’t.

If an app’s only server dependency is AI inference, that inference could now move to the frontend, potentially eliminating the server entirely in some cases. Small, focused apps that previously needed a backend just to proxy an API call can now run the whole thing client-side. That’s a genuine architectural change, not just a blurring of roles. But the backend model overall will remain. It may mediate AI inference or connect to a cloud provider.

The question is no longer “are you a frontend or backend developer?” but “where does each piece of logic actually belong, and what’s the most appropriate place to run it?” AI adds a third answer to that question that didn’t exist before.

Learn more about how AI changes software development at iJS New York (September 28 – October 2, 2026):

Before you continue…

The reading list you'd build – if you had time.

Weekly
Articles + tutorials

The reads you'd find if you had time

2× / mo
Live webinars

Experts you can actually ask

Monthly
Magazine + whitepapers

Deep dives worth your weekend

On-demand
Recordings + courses

Past conferences, ready when you are

Does AI Introduce a New Layer of Architectural Complexity in JavaScript?

AI doesn’t alter JavaScript itself, as basic principles stay the same. It changes how systems are extended and orchestrated. Christian Liebel, frontend specialist and frequent iJS speaker, argues that we should think of “AI as an addition to JavaScript, not a transformation of it.”

In the monolithic era, you added code blocks. In the microservices and micro-fronted era, you added services. Now, you add tools and agents. The core idea is similar, but the connectors, dispatching, and routing logic are different.

Sebastian Springer, a React and Node.js expert and regular iJS speaker, argues that AI introduces a new type of architectural component into modern applications. “Usually, you program the decision into your code: is it true or false, is it A, B, or C? Now, you have a smart node in your application, and this makes the decision. So your software becomes much more flexible, much more dynamic.”

Learn more about how AI changes software architecture at iJS New York (September 28 – October 2, 2026):

Where Does AI Inference Fit in Server-Side JavaScript?

Fundamentally, calling an AI is just another HTTP call to an external service. It’s another tool and another abstraction layer. What’s different now is that instead of structured data, you exchange naturally formulated prompts and then have to force the output back into structure to make it machine-processable. However, the challenge consists of enforcing reliable structured output from inherently probabilistic systems.

Streaming APIs are critical, Liebel adds, and many developers are not yet comfortable building backends around them. Node.js now has the Web Streaming API implemented. It allows sharing streaming code between client and server, which is a significant practical improvement.

AI in the Browser

AI in the browser isn’t just the big hype of generative AI. It includes traditional machine learning. Background blurring in Google Meet, for instance, is a long-established example of an AI model running locally. The main question here is, “where is the AI model executed?” There are two approaches:

Approach 1: Bring your own AI

The website brings its own pre-trained model (e.g., from Hugging Face open-weights models) and runs it inside the JavaScript engine already present in the browser. Relevant APIs include WebGPU and WebNN. On the frameworks side of things, you can use Apache TVM, Transformers.js, or ONNX Runtime Web. 

Approach 2: Built-in AI

The browser itself holds the model and runs it. The developer picks the use case, not the model. This is currently implemented in Chrome and Edge. Chrome uses Gemini Nano, and it’s downloaded into the browser and executed on the device. The prompt API is the language model interface for this approach.

Tradeoffs between the two approaches

Bring your own AI Built-in AI
  • Precise model selection, but it has a storage problem.
  • Same-origin policy means models can’t be shared between websites. If every site brings a 5GB model, storage fills up fast.
  • Can use full native performance by running on bare metal rather than through abstraction layers.
  • WebGPU/WebNN are 10-15% slower by comparison, but, in this case, the developer doesn’t know which model they get. Quality can vary unpredictably.

 

“Running AI locally is sort of edge AI, meaning on the user’s device itself. Nobody else can see it. It’s offline-capable and very good for privacy,” says Christian Liebel.

When Local AI Beats the Cloud

There are some cases where local/in-browser AI makes more sense than cloud inference. Liebel identifies the following four points:

  • Cost: No cloud inference costs. This is relevant for companies and hobbyists who want AI features but can’t afford per-user inference fees.
  • Latency: Example: background blurring. Sending video frames to a server and back would be too slow. Local processing is near zero latency.
  • Privacy: Prompt and input data never leave the device.
  • Offline capability: Works regardless of connection state.

Security and Privacy in AI-Powered Web Apps

We need to make a distinction between two security dimensions: AI-assisted attacks, when attackers use AI to find vulnerabilities, and AI-introduced vulnerabilities that emanate from integrating AI into applications.

AI models are good at pattern detection and can find vulnerabilities much more effectively than humans. The errors aren’t necessarily more numerous, they’re just more detectable. Anthropic’s Mythos project is a striking example of this. It uncovered a bug in BSD, a widely used operating system, that had gone undetected by humans for roughly 30 years. This is a reminder of how fragile the IT world actually is and how much AI can see what we can’t.

A practical countermeasure for AI-assisted attacks, suggested by Springer, is to make AI-based security analysis a standard part of your software development process. Use the tools your attackers would use before they can use them against you. AI-assisted vulnerability scanning should be part of your pipeline.

These security concerns exist because AI is increasingly becoming part of the application itself. That raises another question: if AI becomes a native building block of web applications, does it also change what an application looks like?

Before you continue…

The reading list you'd build – if you had time.

Weekly
Articles + tutorials

The reads you'd find if you had time

2× / mo
Live webinars

Experts you can actually ask

Monthly
Magazine + whitepapers

Deep dives worth your weekend

On-demand
Recordings + courses

Past conferences, ready when you are

From Websites to AI-Native Applications

Applications have traditionally been organized around pages. Users navigate through menus, search results, forms, and workflows to reach the information or functionality they need. AI-powered interfaces challenge that model.

Nir Kaufman, fullstack developer, AI instructor and keynote speaker at iJS, describes a future in which users no longer navigate complete applications. Instead, they interact with an AI assistant that guides them through a conversation while dynamically assembling “the entire website chopped into tiny pieces and streamed to me.” A map appears when location matters, a booking component when a reservation is needed, and a payment widget when it’s time to complete a transaction.

This represents more than a new frontend pattern. It changes the role of the application itself. Rather than guiding users through pages and navigation structures, applications expose capabilities that can be invoked as part of an ongoing conversation. The website becomes less of a destination and more of a collection of services, data, and interaction components that an AI assistant can draw upon when needed.

For developers, Kaufman believes this may require a different way of constructing applications. Instead of organizing software around pages, routes, and navigation hierarchies, applications may increasingly be built as collections of reusable components that can be selected and assembled dynamically. In this model, developers design capabilities and interactions rather than predefined user journeys.

Emerging approaches such as Google’s Agent-to-UI (A2UI) protocol explore this idea. Agents describe interface elements as structured objects, while clients render the appropriate user interface on demand. Rather than sending complete screens, systems can stream focused interaction components into an ongoing conversation as they become relevant.

Viewed this way, AI is not simply another feature added to existing systems. It becomes a new access layer between users and applications. The architectural challenge shifts from designing pages and user flows to designing capabilities that can be discovered, combined, and orchestrated dynamically.

Learn more about how AI changes web applications at iJS New York (September 28 – October 2, 2026):

Designing AI-Native User Experiences

If AI becomes a new access layer to applications, the next challenge is no longer primarily technical. It is about designing interactions that people can understand, trust, and control.

Christian Kuhn, UX researcher and consultant, approaches this question through a set of six design principles. Rather than treating AI as a feature, he focuses on how people collaborate with intelligent systems. The principles emphasize human agency, empathy, transparency, controllability, and the ability to recover from mistakes.

This perspective starts with a fundamental shift in how interactions are conceived. As Kuhn puts it, “The central unit of interaction is no longer the page but the conversation itself.” Users increasingly express goals, preferences, and constraints, while the system determines how best to support them. The challenge for designers is therefore not only to create interfaces, but also to shape the interaction between human intent and machine capabilities.

Kuhn’s principles provide a practical framework for this challenge:

  • Human First: AI should amplify human capabilities rather than replace human judgment.
  • Empathy First: Systems should understand user context and needs instead of forcing users to adapt to the machine.
  • Automation vs. Augmentation: Not every task should be fully automated. Users often benefit from remaining active participants in the process.
  • Transparency and Confidence: Users need to understand where information comes from and how reliable it is.
  • Control and Editability: AI-generated results should remain editable, reversible, and subject to user control.
  • Mental Models and Graceful Failure: Systems must help users understand how they work and provide clear recovery paths when mistakes occur.

Several of these principles become increasingly important as AI systems move beyond the chat interface. In Kuhn’s view, “Text writing is de facto the most exhausting and worst input mode.” As voice, vision, and contextual awareness continue to mature, conversational experiences are likely to expand far beyond typing into a text box.

For UX designers, this creates a new design discipline. The goal is no longer limited to optimizing screens, navigation paths, or workflows. It increasingly involves designing how people and AI systems cooperate, communicate, and recover when things go wrong.

Learn more about how AI changes user experience at iJS New York (September 28 – October 2, 2026):

Where Is Web Development Heading?

The architecture of web applications is changing. So is the way users interact with them. As AI becomes part of the web stack, questions that once belonged primarily to developers increasingly overlap with concerns traditionally associated with architects, product teams, and UX designers. Decisions about inference, interfaces, workflows, and user guidance can no longer be treated as separate domains.

This makes collaboration more important, not less. Building AI-powered web applications requires technical, architectural, and experiential perspectives to work together much earlier in the process than many teams are accustomed to today.

At the same time, the emergence of AI does not invalidate the foundations of software engineering. If anything, it reinforces them. Clean architectures, clear documentation, reliable automation, comprehensive testing, strong observability, and security that is considered from the beginning rather than added later remain essential. As systems become more dynamic and more complex, these disciplines become increasingly important.

For web developers, architects, and UX designers alike, the challenge is therefore twofold: to remain open to new architectural and interaction models while continuing to apply the engineering principles that have always been necessary to build reliable software.

The post AI, JavaScript, and the End of the Website appeared first on International JavaScript Conference.

]]>
Angular 22: What’s New in the Latest Release https://javascript-conference.com/blog/angular-22-new-features-onpush-resource-api/ Thu, 04 Jun 2026 09:32:02 +0000 https://javascript-conference.com/?p=209949 In this version, the Angular team has prioritized comprehensive optimization: from the new default OnPush strategy and the fully stable Resource API to seamless accessibility integration. The framework is becoming leaner, faster, and significantly more intuitive. If you have been wondering when the time would come to fully embrace 'next-generation Angular,' the moment is now. In this article, we will explore the key changes in version 22 and how they redefine modern frontend development.

The post Angular 22: What’s New in the Latest Release appeared first on International JavaScript Conference.

]]>
The New Standard: OnPush as the Default

In Angular 22, OnPush is no longer just an optional performance optimization. It is now the default for new components. This change goes beyond performance and reflects the direction Angular is taking with signals. This shift didn’t happen overnight. It is the final step of a carefully planned transition.

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

The Roadmap to the New Default:

  • The Zoneless Foundation: Starting with version 21, the framework enabled zoneless support by default, signaling the end of the zone.js era and the beginning of a lighter, faster runtime.
  • The Deprecation of “Default:” In version 21.2, the old ChangeDetectionStrategy.Default was officially marked as deprecated. It was essentially rebranded as the Eager strategy, a new, more descriptive name that better reflects what this mode actually does in Angular.
  • The Angular 22 Milestone: Now, in version 22, Angular comes full circle. When you create a new component, OnPush is applied automatically. What used to be a performance-oriented choice is now the default way Angular components work.

What does this mean for your existing projects?

If you’re upgrading an existing codebase, you don’t need to worry about a “breaking change” headache. The Angular team has built an intelligent migration tool into the ng update process that handles the transition automatically.

change detection

When you run the update, the migrator automatically scans your components. Any component that relied on the old, implicit Default behavior is updated to ChangeDetectionStrategy.Eager (Listing 1). This ensures your application behaves exactly as it did before, with no hidden changes to the logic.

You are not forced into OnPush for your entire codebase. The migration preserves your existing behavior while bringing your project in line with the new Angular standards.

Listing 1

@Component({
  selector: 'app-captain-dashboard',
  imports: [
    CrewWidgetComponent,
  ],
  templateUrl: './captain-dashboard.component.html',
  changeDetection: ChangeDetectionStrategy.Eager,
  styleUrl: './captain-dashboard.component.scss',
})
export class CaptainDashboardComponent {

The Stable Milestone: Resource API, Signal Forms & Angular Aria

Angular 22 marks an important step in the maturity of several newer APIs. Resource API, Signal Forms, and Angular Aria are now stable, making them much easier to adopt in production.

  • Resource API has come a long way. Introduced in Angular 19 as an experimental feature, it quickly drew a lot of attention from the community. Now, after several iterations and refinements, resourcehttpResource, and rxResource are finally stable.
  • Signal Forms are also stable now, bringing a more modern approach to handling form states. Angular 22 also includes more detailed documentation and improved compatibility with Angular Material and Angular Aria. One nice improvement worth mentioning is the new debounce option for validateAsync and validateHttp. Previously, debouncing had to be applied at the control level, so it affected all validators attached to that control, including synchronous ones like required. That delayed instant feedback for simple checks just to accommodate async calls.
  • Angular Aria is now stable as well, which is especially important for teams building accessible custom components. It gives developers a more reliable foundation for production use.

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Simplifying Dependency Injection: The @Service Decorator

Angular is making dependency injection a bit more straightforward. The new @Service() decorator is intended to cover most of the cases where we previously used @Injectable({ providedIn: ‘root’ }), giving us a cleaner and simpler way to define services.
Before Angular 22, the CLI generated services with @Injectable({ providedIn: ‘root’ }), as shown in Listing 2. In Angular 22, the same command now uses the new @Service() decorator by default (Listing 3), keeping the same root-provided behavior but with a shorter, more focused syntax.

Listing 2

@Injectable({
  providedIn: 'root'
})
export class CatService {

Listing 3

@Service()
export class CatService {

Why the switch?

The biggest win here is getting rid of boilerplate. @Injectable is very flexible, but it exposes options like useValueuseClass, or useExisting that most simple services never need.

@Service is much more opinionated. It is aimed at the common case of a singleton service provided in the root injector. By limiting the configuration surface (you only get a simple factory function if you really need it), it helps keep service definitions straightforward.

On top of that, @Service is designed to be used with the inject() function instead of constructor injection, which keeps dependencies closer to where they are used and makes the class easier to read at a glance. If you try to use constructor injection with @Service, Angular will now fail the build with an error.

Listing 4

@Service()
export class CatService {
  
    constructor(http: HttpClient) {
    }
}

The image displays a code snippet error message in a software development environment, specifically related to a TypeScript file in an Angular project, indicating an issue with a service class's constructor dependency injection

Turning off automatic provisioning

By default, @Service() registers the class in the root injector. In some cases, though, you may want the service to exist only within a smaller part of the UI. That is where autoProvided: false comes in (Listing 5). It lets you skip automatic provisioning and add the service manually where you need it.

For example, if a service should live only as long as a specific component is active, you can provide it in that component’s providers array (Listing 6).

Listing 5

@Service({ autoProvided: false })
export class DraftWorkspaceService {
 
}

Listing 6

@Component({
  selector: 'app-draft-editor',
  providers: [DraftWorkspaceService],
  template: `...`,
})
export class DraftEditorComponent {
  protected draftWorkspace = inject(DraftWorkspaceService);
}

If you forget to add the service to the component’s providers array, Angular will fail to resolve it and throw an error, as shown in the screenshot below.

Build error shown after using constructor injection with @Service()

When should you stick with @Injectable?

Use @Injectable() when:

  • Your service requires more advanced provider configuration
  • You need constructor injection
  • The service should live in a non-root scope, such as providedIn: ‘platform’.

In practice, @Service() becomes the new default for simple, root-scoped services, while @Injectable() remains the better tool for advanced DI scenarios. The split is quite clean: use @Service() for the common, simple cases, and switch to @Injectable() when the service needs more flexibility.

Lazy-loaded services with injectAsync()

While @Service() simplifies the common case, Angular now goes one step further with injectAsync(), which is especially useful for lazy-loaded services. Angular has always had a strong dependency injection system, but loading services only on demand has often required more manual work. InjectAsync() makes that much easier by letting Angular resolve a service asynchronously, exactly when it becomes necessary.

This is especially useful when a service depends on a heavy library or supports a feature that is only used occasionally, such as a rich text editor, because you want to keep the initial page load as light as possible. When the service is first requested, the bundler loads a separate JavaScript chunk, and Angular then resolves the service like any other singleton.

Important: Lazy loading works only with auto-provided services, such as @Injectable({ providedIn: ‘root’ }) or @Service().

The example below (Listing 7) shows a dashboard component that loads its chart service asynchronously when it is actually needed. The service is fetched only when showCharts() is called, and only the first call triggers the actual download.

Listing 7

@Component({
  selector: 'app-dashboard',
  template: `...`,
})
export class DashboardComponent {
  private charts = injectAsync(() =>
    import('./dashboard-charts.service').then((m) => m.DashboardChartsService)
  );


  async showCharts() {
    const charts = await this.charts();
    charts.render();
  }
}

If you want to start the download earlier, you can pass a prefetch trigger in the options. By default, it is onIdle (Listing 8), a built-in trigger that waits until the browser becomes idle before starting the load. You are not limited to onIdle, a prefetch trigger can be any function that returns a promise.

Listing 8

export class DashboardComponent {
  private charts = injectAsync(() =>
    import('./dashboard-charts.service').then((m) =>m.DashboardChartsService),
	{ prefetch: onIdle }
  );

Browser URL Support for RouterLinks

Angular 22 introduces a small but useful routing improvement. RouterLink now gets a new browserUrl input. It allows you to set a different URL in the browser than the one Angular uses internally for navigation.

This gives you more control over what users see in the address bar. The app can keep its internal route logic unchanged, while the visible URL feels more user-friendly.

Why it matters:

  • Cleaner and more readable URLs.
  • A visible URL that can differ from the internal route.
  • Better support for aliases or alternative navigation paths.
  • More flexibility in how navigation is presented to users.

This small API addition gives developers more flexibility when shaping how routes appear to users.

debounced() for Signals

Another interesting addition is debounced(), currently available as an experimental feature. It brings a built-in debounce mechanism directly to signals, filling a noticeable gap in signal-based code.

The Problem It Solves

This is especially relevant in UI patterns like autocomplete or search inputs. When an input changes on every keystroke, you usually do not want to trigger an API call immediately each time. Without debouncing, that can easily lead to too many requests while the user is still typing.

Until now, the usual workaround involved a multi-step loop: converting the signal to an Observable, applying RxJS debounceTime, and then converting the result back into a signal. While it worked, it added unnecessary complexity to something that should feel native.

Powered by the Resource API

What makes debounced() so useful is that it gives you both the debounced value and its state in one place. Instead of returning a plain signal, it returns a Resource, allowing you to track exactly what is happening under the hood:

  • loading state: while the timer is running, the resource stays in a loading state and continues to expose the previously settled value.
  • resolved state: once the specified delay passes, the resource automatically moves to resolved with the new value.
  • errors: if the source signal throws an error, the resource switches to an error state immediately.

Automatic cleanup

Because debounced() runs inside an injection context, Angular handles cleanup automatically. When the injector is destroyed, Angular clears any pending timer and disposes of the resource without any extra code.

Example

In practice, this fits naturally into features like user search or autocomplete.

Listing 9

export class UserSearch {
  userQuery = signal('');
  debouncedUserQuery = debounced(this.userQuery, 400);


  results = httpResource<User[]>(() => {
    const search = this.debouncedUserQuery.value();
    return `http://localhost:3000/api/users?query=${search}`;
  });
}

Here, userQuery updates immediately with every keystroke while debouncedUserQuery settles only after 400ms of inactivity. As a result, httpResource does not fire a new request on every single character.

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Template comments

Angular now supports both single-line and block comments in templates, which makes it much easier to leave notes right where they matter. That kind of support may sound minor, but it removes one of those tiny everyday annoyances that developers have simply learned to live with. And when a framework takes care of little things like this, the whole developer experience feels smoother.

The example below shows both single-line and block comments in an Angular template.

Listing 10

<div 
  //Single line comment
  class="card">
  
  <h2>User profile</h2>
  <button  
    /* Multi-line 
      comment
      continues here */
    type="button">Edit</button>
</div>

WebMCP

Angular’s latest updates are not only about improving the framework’s core developer experience and performance. The framework is also starting to explore how modern web applications can better integrate with AI-powered workflows.

Web applications are becoming increasingly AI-aware, but communication between AI agents and web apps is still limited. Most agents still rely on DOM inspection and inferred behavior, which makes the whole process fragile and harder to trust.

This is the problem WebMCP aims to solve. Introduced by the Chrome team at Google, WebMCP proposes a more structured way for AI agents to communicate with web applications by exposing actions and workflows directly in the browser. Instead of forcing agents to interpret the entire UI, applications can explicitly define what actions are available and how they should be executed.

While the proposal is still experimental, Angular has already introduced early support for WebMCP, making it easier to integrate AI-friendly capabilities directly into Angular applications.

Currently, Angular provides several integration levels for WebMCP:

  • Global Availability with Application Scope:

When you want to expose features that should be accessible regardless of where the user is currently navigating, you use the application scope. By registering tools using provideExperimentalWebMcpTools within your app.config.ts (Listing 11), you make those capabilities available to AI agents across the entire lifecycle of the application.

A key advantage here is that the execution of these tools runs within an injection context. This means that inside the execute function, you can directly use inject() to access your services.

Listing 11

export const appConfig: ApplicationConfig = {
  providers: [
    provideExperimentalWebMcpTools([
      {
        name: 'searchAdoptionPets',
        description: 'Search the pet adoption registry for animals ready for adoption based on species, age group, and behavioral traits.',
        inputSchema: {
          type: 'object',
          properties: {
            query: { type: 'string', description: 'Search keywords.' },
            species: { type: 'string', enum: ['Dog', 'Cat', 'Rabbit'], description: 'The type of animal.' },
            ageGroup: { type: 'string', enum: ['Puppy/Kitten', 'Young', 'Adult', 'Senior'], description: 'The age group of the animal.' },
          },
          required: ['query', 'species'],
          additionalProperties: false,
        },
        execute: ({ query, species, ageGroup }) => {
          const animals = inject(AnimalsService).search(query, species, ageGroup);
          return { content: [{ type: 'text', text: JSON.stringify(animals) }] };
        },
      },
    ]),
  ]
};
  • Route Scope:

You can also attach WebMCP tools to specific routes by providing provideExperimentalWebMcpTools directly within your route configuration (Listing 12).

However, to ensure this integration behaves predictably, you should configure the router to use withExperimentalAutoCleanupInjectors (Listing 13). This is the key that enables Angular’s automatic cleanup mechanism. When the user navigates away from a route, the framework immediately unregisters the associated AI tools.

Listing 12

export const routes: Routes = [
    {
        path: 'shelter-management',
        loadComponent: () => import('./pages/management/management.component').then((m) => m.ManagementComponent),
        providers: [
            provideExperimentalWebMcpTools([
                {
                    name: 'calculateWeeklySupplyNeeds',
                    description: 'Calculates weekly food and medical supply requirements for a specific shelter zone based on current animal occupancy.',
                    inputSchema: {
                        type: 'object',
                        properties: {
                            sectionName: { 
                                type: 'string', 
                                enum: ['Dog Quarantine', 'Cat Pavilion', 'Small Mammals'], 
                                description: 'The target shelter zone.' 
                            },
                            includeBufferStock: { 
                                type: 'boolean', 
                                description: 'Set to true if the user asks for an extra safety margin or buffer for new arrivals.', 
                                default: false 
                            }
                        },
                        required: ['sectionName'],
                        additionalProperties: false,
                    },
                    execute: ({ sectionName, includeBufferStock }) => {
                        const inventoryService = inject(ShelterInventoryService);
                        const report = inventoryService.calculateSupplies(sectionName, includeBufferStock);
                        return { content: [{ type: 'text', text: JSON.stringify(report) }] };
                    },
                },
            ]),
        ],
    },
];

Listing 13

import { provideRouter, withExperimentalAutoCleanupInjectors } from '@angular/router';


export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes, withExperimentalAutoCleanupInjectors()),
  ]
};
  • Service Scope:

You can also bind WebMCP tools directly to individual services using declareExperimentalWebMcpTool. The tool’s lifecycle is tied strictly to the service instance: it becomes available when the service is initialized and is automatically unregistered when the service is destroyed.

Listing 14

@Service()
export class AdoptionApplicationService {
  readonly activeApplication = signal<AdoptionApplication | null>(null);
  
  constructor() {
    declareExperimentalWebMcpTool({
        name: 'getAdoptionApplicationStatus',
        description: 'Returns the current evaluation status and pending tasks for the active pet adoption application.',
        inputSchema: { type: 'object', properties: {} },
        execute: () => ({
            content: [{ type: 'text', text: JSON.stringify(this.activeApplication()) }],
        }),
    });
  }
}

  • Signal Forms Scope:

This scope bridges the gap between AI assistants and user input by turning standard Angular Signal Forms into intelligent, AI-ready endpoints. Once you register the feature globally via provideExperimentalWebMcpForms (Listing 15), enabling it is as simple as adding the experimentalWebMcpTool property directly to your form definition.

Listing 15

import { provideExperimentalWebMcpForms } from '@angular/forms/signals';


export const appConfig: ApplicationConfig = {
  providers: [
    provideExperimentalWebMcpForms()
  ]
};

The biggest advantage of this scope is zero-boilerplate schema generation. Angular inspects your form’s data model and active validators to dynamically present a structured schema to the browser’s AI. The agent can then fill out and submit the entire form programmatically, handling validation errors directly just like a human user would.

Listing 16

readonly volunteerForm = form(
    this.model,
    (f) => {
      required(f.fullName);
      required(f.email);
      required(f.experience);
      minLength(f.experience, 30);
    },
    {
      experimentalWebMcpTool: {
        name: 'submitVolunteerApplication',
        description: 'Submits an application to become a shelter volunteer. Requires fullName, email, and an experience description of at least 30 characters.',
      },
      submission: {
        action: async (value) => this.volunteerService.registerPendingVolunteer(value),
      },
    },
  );
}

Important: Both WebMCP and Angular’s integration with it are still in an early phase, so some APIs and behaviors may change in future updates.

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Conclusion

Angular 22 brings a strong mix of stabilization and new ideas, giving developers both a more solid foundation and a few genuinely interesting directions to explore. The official stabilization of the Resource API, Signal Forms, and Angular Aria makes the framework feel more production-ready in the areas that matter most. At the same time, Angular keeps adding features that solve real-world problems more directly, from routing tweaks to signal-based debouncing and new dependency injection capabilities. Of course, these are only some of the highlights. Angular 22 also includes bug fixes and other improvements that help round out the release.

What stands out most is the direction of these changes. Angular is continuing to reduce friction, replace workarounds with native solutions, and make the developer experience feel smoother without losing the framework’s maturity. It is a release that feels less like a reinvention and more like a confident step forward.

Angular keeps proving that maturity does not have to mean stagnation. What are you most excited to try first in Angular 22?

The post Angular 22: What’s New in the Latest Release appeared first on International JavaScript Conference.

]]>
Watch Session: An AI Assistant for Your Angular Applications https://javascript-conference.com/blog/watch-session-an-ai-assistant-for-your-angular-applications/ Wed, 13 May 2026 11:18:36 +0000 https://javascript-conference.com/?p=209923 AI assistants are becoming an important part of modern web applications. They can help users complete tasks, answer questions, navigate workflows, and interact with application features more naturally. In this session, you’ll learn how Angular applications can use AI assistants to create smarter and more dynamic user experiences.

The post Watch Session: An AI Assistant for Your Angular Applications appeared first on International JavaScript Conference.

]]>

What Is an AI Assistant in Angular?

An AI assistant in an Angular application is more than a simple chatbot. It can understand application context, guide users, and interact with frontend services.

  • It can help users complete tasks inside the application.
  • It can use application state, forms, and routes as context.
  • It can support smarter and more personalized user experiences.

What Is Agentic UI?

Agentic UI allows AI assistants to take action inside the user interface. Instead of only replying with text, the assistant can suggest actions, generate interface elements, and help users move through workflows.

  • AI can become part of the frontend experience.
  • Users can interact with applications in a more natural way.
  • Interfaces can become more dynamic and context-aware.

Using Angular Context

Angular applications already manage a lot of useful context. This includes state, routing, forms, services, and user interactions. When this context is made available to an AI assistant, the assistant can provide better and more relevant support.

  • State management helps the assistant understand current data.
  • Routing helps the assistant understand where the user is.
  • Forms allow the assistant to support input and workflow completion.

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Tool-Calling with Angular Services

Tool-calling allows an AI assistant to interact with application functions and services. In Angular, this can be used to connect the assistant with backend APIs, business logic, or frontend actions.

  • Angular services can expose useful functions to the assistant.
  • AI can help trigger actions inside the application.
  • Tool-calling makes the assistant more practical and interactive.

Dynamic UI Generation

One of the most powerful ideas in Agentic UI is dynamic interface generation. AI assistants can help create or suggest UI elements based on user needs, current context, or application data.

  • Interfaces can adapt to the user’s goal.
  • AI can suggest relevant next steps.
  • Applications can become more flexible and intelligent.

Conclusion

AI assistants are changing the way users interact with web applications. For Angular developers, this creates new opportunities to build smarter interfaces that understand context, support workflows, and provide meaningful assistance.

By combining Angular, application state, routing, services, and Agentic UI concepts, developers can create applications that go beyond traditional frontend experiences.

Watch the full session below:

The post Watch Session: An AI Assistant for Your Angular Applications appeared first on International JavaScript Conference.

]]>
Tool Calling in the Frontend with Hashbrown https://javascript-conference.com/blog/tool-calling-frontend-hashbrown-angular/ Thu, 23 Apr 2026 08:36:03 +0000 https://javascript-conference.com/?p=209872 Hashbrown streamlines the complexity of integrating AI assistants into web apps for Angular and other frontend frameworks. Learn how to implement tool calling in the frontend, connect to providers like OpenAI and Google, and securely route requests through a lightweight backend.

The post Tool Calling in the Frontend with Hashbrown appeared first on International JavaScript Conference.

]]>
AI-based assistants improve the user experience and reduce support costs. But implementing them involves a lot of routine technical work, like connecting different LLMs and implementing tool calling. Hashbrown takes this work off our hands. The open-source project, supported by two well-known figures in the Angular community, supports all relevant model providers such as Gemini (Google), GPT (OpenAI), Azure (Microsoft), and Llama (Meta).

This article shows how to extend an existing Angular application with Hashbrown to include a chat assistant. The source code for the demo app is available on GitHub.

Sample application

This sample application is the flight search, which I’ll use to demonstrate several Angular features. Figure 1 shows the chat window that can be displayed on the right-hand side with an example chat history.

Fig. 1: Example application

Fig. 1: Example application

As this chat history shows, the assistant can request additional data and trigger actions in the application as needed. This is made possible by tool calling: the LLM prompts the app to perform a specific function and return the results. These tool calls appear in the chat history as requested by the LLM, with the parameters { from: ‘Graz’, to: ‘Hamburg’ } for findFlights omitted.

While chat messages such as Tool Call: findFlights inform developers about internal processes, this information is likely to be confusing for end users. So it makes sense to translate this technical information into something like Load flights from Graz to Hamburg.

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Setting up Hashbrown

To use Hashbrown, we need a few npm packages:

npm install @hashbrownai/{core,angular,google}

The @hashbrown/angular package includes an Angular-based API for the core framework-agnostic library. A framework binding for React is also currently available. The @hashbrown/google package provides access to Google’s Gemini models. Hashbrown offers additional packages for other model families (such as @hashbrown/openai).

For programmatic access to LLMs, the application needs an API key, which is usually linked to a paid license. However, Google’s Gemini has a comprehensive free package for testing purposes. An API key can be generated in Google AI Studio with just a few clicks. The required menu item is available in the dashboard.

To prevent the API key from being published, it must not be used directly in the Angular front end. Instead, use a very narrow back end that serves as an intermediary between the front end and LLM (Listing 1).

Listing 1

// Taken from hasbrown.dev

import express from 'express';
import cors from 'cors';
import { Chat } from '@hashbrownai/core';
import { HashbrownGoogle } from '@hashbrownai/google';

const host = process.env['HOST'] ?? 'localhost';
const port = process.env['PORT'] ? Number(process.env['PORT']) : 3000;

const GOOGLE_API_KEY = process.env['GOOGLE_API_KEY'];
if (!GOOGLE_API_KEY) {
  throw new Error('GOOGLE_API_KEY is not set');
}

const app = express();

app.use(cors());
app.use(express.json());

app.post('/api/chat', async (req, res) => {
  const completionParams = req.body as Chat.Api.CompletionCreateParams;

  const response = HashbrownGoogle.stream.text({
    apiKey: GOOGLE_API_KEY,
    request: completionParams,
    transformRequestOptions: (options) => {

      options.model = 'gemini-2.5-flash';

      options.config = options.config || {};
      options.config.systemInstruction = `
      You are Flight42, an UI assistent that helps passengers with finding flights.

      - Voice: clear, helpful, and respectful.
      - Audience: passengers who want to find flights or have questions about booked flights.
      
      Rules:
      - Only search for flights via the configured tools
      - Never use additional web resources for answering requests
      - Do not propose search filters that are not covered by the provided tools
      - Do not propose any further actions
      - Provide enumerations as markdown lists
      `;

      return options;
    },
  });

  res.header('Content-Type', 'application/octet-stream');

  for await (const chunk of response) {
    res.write(chunk);
  }

  res.end();
});

app.listen(port, host, () => {
  console.log(`[ ready ] http://${host}:${port}`);
});

The implementation of this backend, which was taken in part from the Hashbrown documentation, expects the API key to be stored in the GOOGLE_API_KEY environment variable. On MacOS and Linux, this can be done with:

export GOOGLE_API_KEY=abcde...

And on Windows with:

set GOOGLE_API_KEY=abcde...

With transformRequestOptions, the backend can supplement or override the options set by the frontend—an important mechanism, as these settings have direct cost implications. In the example, the backend enforces the inexpensive all-round model gemini-2.5-flash and defines a system instruction that strictly limits the model to flight searches. This prevents users from consuming expensive LLM resources for unrelated queries.

Before overwriting, the frontend’s original values are stored in model and systemInstructions. This allows controlled negotiation. At the user’s request, the server can switch to a more powerful (but more expensive) model in selected cases or adjust the system instructions.

When the Angular application is started, this minimal server’s URL is configured via provideHashbrown (Listing 2).

Listing 2

import { provideHashbrown } from '@hashbrownai/angular';
[…]

bootstrapApplication(AppComponent, {
  providers: [
    provideHttpClient(),

    […]
    
    provideHashbrown({
      baseUrl: 'http://localhost:3000/api/chat',
      middleware: [
        (req) => {
          console.log('[Hashbrown Request]', req);
          return req;
        }
      ]
    }),
  ],
});

The optional middleware specified here logs all requests to the server on the JavaScript console. These messages give us a better understanding of how such systems work and also help with troubleshooting.

Chatting with the AI of your choice

Hashbrown provides several implementations of Angular’s Resource API for chatting with the LLM. For our purposes, we’ll use chatResource (Listing 3).

Listing 3

@Component({ … })
export class AssistantChatComponent {

  […]
  message = signal('');

  chat = chatResource({
    model: 'gemini-2.5-flash',
    system: `
      You are Flight42, an UI assistant that helps passengers with finding flights.
      […]
    `,
    tools: [
      findFlightsTool,
      toggleFlightSelection,
      showBookedFlights,
      getBookedFlights,
      […]
    ],
  });

  submit() {
    const message = this.message();
    this.message.set('');
    this.chat.sendMessage({ role: 'user', content: message });
  }

  […]
}

The chatResource provides the stateless LLM with the complete chat history for each request. This allows the model to refer to previous statements. For example, if a conversation revolves around flight #4711, the LLM recognizes what is meant by “this flight.”

The chatResource also supports tool calling. The tools property provides the model with all the functionalities provided by the front end, like findFlights for flight searches. The technical implementation of the tools can be found below. The value of the chatResource contains the chat history to be displayed (Listing 4).

Listing 4

@for (message of chat.value(); track $index) {
<article class="msg assistant">
  <div class="avatar">{{ icons[message.role] }}</div>
  <div>
    <div class="bubble">
      {{ message.content }} 
      
      @if (message.role === 'assistant') { 
        @for(toolCall of message.toolCalls; track toolCall.toolCallId) {
          <div [title]="toolCall.args | json">
            Tool Call: {{ toolCall.name }}
          </div>
        } 
      }
    </div>
  </div>
</article>
}

The role property specifies who sent the chat message. For example, the value assistant indicates messages from the LLM, while user indicates messages from the front-end user. Messages from the LLM can also contain requests for tool calls, which are also presented in the template shown. Each tool call refers to the name of the desired tool (e.g., findFlights) and the arguments to be passed (e.g., { from: ‘Graz’, to: ‘Hamburg’ }).

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Providing tools

The tools provided are objects that the application creates with the createTool function (Listing 5).

Listing 5

import { createTool } from '@hashbrownai/angular';
import { s } from '@hashbrownai/core';
[…]

export const findFlightsTool = createTool({
  name: 'findFlights',
  description: `
  Searches for flights and redirects the user to the result page where the found flights are shown. 
  
  Remarks:
  - For the search parameters, airport codes are NOT used but the city name. First letter in upper case.
  `,
  schema: s.object('search parameters for flights', {
    from: s.string('airport of departure'),
    to: s.string('airport of destination'),
  }),
  handler: async (input) => {
    const store = inject(FlightBookingStore);
    const router = inject(Router);

    store.updateFilter({
      from: input.from,
      to: input.to,
    });

    router.navigate(['/flight-booking/flight-search']);
  },
});

The tool name must be unique and comply with the model specifications. Here’s a practical rule of thumb: anything that is permitted as a variable name in TypeScript should also work here. The LLM uses the description to decide if the tool is relevant for the current task. The schema defines the arguments that the model must pass—in the example, an object with the search parameters from and to. Here the LLM is guided by the stored textual descriptions.

Hashbrown uses its own schema language, Skillet, to define this structure. It’s similar to the Zod library, but is reduced to constructs that reliably support LLMs. Future Hashbrown versions will also support JSON Schema and bridging to Zod.

The handler implements the tool: it receives the object defined in the schema and delegates the task to the system logic, such as the store or the router. Handlers can also return values to the model, like the getLoadedFlights tool (Listing 6).

Listing 6

export const getLoadedFlights = createTool({
  name: 'getLoadedFlights',
  description: `Returns the currently loaded/ displayed flights`,
  handler: () => {
    const store = inject(FlightBookingStore);
    return Promise.resolve(store.flightsValue());
  },
});

The return value is not formally described in Skillet—the model accepts any form of response. If the front end wants to provide the model with information about the structure of the delivered result, this can be done as free text in the description field.

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Under the hood

A look at the messages sent to the LLM shows how tool calling works (Listing 7):

  • Hashbrown sends the user’s text search query in the user role to the model.
  • The model responds in the assistant role with a tool call. This includes the name of the tool and the arguments to be passed.
  • Hashbrown triggers the tool, which handles the search and route change.
  • Hashbrown reports back in the tool role that the tool call has been completed. If the tool had returned a result, Hashbrown would include it in this message.
  • The model responds in the assistant role.

To ensure that the LLM is aware of the tools offered, these are transferred together with metadata in the tools section at the end. Here, you’ll find textural descriptions stored in the source code and the schema definitions of expected arguments.

Listing 7

{
  "model": "gpt-5-chat-latest",
  "system": "You are Flight42, an UI assistant [...]",
  "messages": [
    [...],
    {
      "role": "user",
      "content": "Ok, let's search for flights from Graz to Hamburg."
    },
    {
      "role": "assistant",
      "content": "",
      "toolCalls": [
        {
          "id": "call_AeFJ3xsnNw29EoQVo7hR9Qtu",
          "index": 0,
          "type": "function",
          "function": {
            "name": "findFlights",
            "arguments": "{\"from\":\"Graz\",\"to\":\"Hamburg\"}"
          }
        }
      ]
    },
    {
      "role": "tool",
      "content": {
        "status": "fulfilled"
      },
      "toolCallId": "call_AeFJ3xsnNw29EoQVo7hR9Qtu",
      "toolName": "findFlights"
    },
    {
      "role": "assistant",
      "content": "Here are the available flights [...]",
      "toolCalls": []
    }
  ],
  "tools": [
    {
      "description": "Searches for flights [...]",
      "name": "findFlights",
      "parameters": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "type": "object",
        "properties": {
          "from": {
            "type": "string",
            "description": "airport of departure"
          },
          "to": {
            "type": "string",
            "description": "airport of destination"
          }
        },
        "required": [
          "from",
          "to"
        ],
        "additionalProperties": false,
        "description": "search parameters for flights"
      }
    },
    [...]
  ]
}

For a better visualization, Figure 2 shows the process as a sequence diagram. This diagram also shows the backend, which allows the frontend to access the model.

Fig. 2: Message history with Tool Calling

Fig. 2: Message history with Tool Calling

Conclusion

Hashbrown makes it easy to add chat-based AI assistants to front-end applications. It handles complex tasks like LLM integration and tool calling, allowing developers to focus on actual business benefits. In just a few steps, you can create an assistant that controls user interactions, triggers application functions, and responds contextually.

In practice, it’s important to note that LLMs do not work deterministically—the same query can lead to slightly different results. It’s also worth refining tool descriptions step-by-step and testing them with typical sample queries to achieve reliable interaction between the model and the application.

The post Tool Calling in the Frontend with Hashbrown appeared first on International JavaScript Conference.

]]>
If You Want to Understand Modern Angular with Signals, You Have to Relearn Angular https://javascript-conference.com/blog/modern-angular-signals-agentic-ui/ Tue, 31 Mar 2026 11:53:48 +0000 https://javascript-conference.com/?p=209838 Angular has undergone a fundamental transformation—not a gradual one, but a complete overhaul. The reactivity model is new, the testing framework is new, and at the forefront of this evolution is Agentic UI: the ability to integrate language models directly into the application architecture. This is more than just an API update.

The post If You Want to Understand Modern Angular with Signals, You Have to Relearn Angular appeared first on International JavaScript Conference.

]]>
How SignalStore, Agentic UI, and modern testing fit together

I learned more about this in a conversation with Manfred Steyer. Manfred has been training enterprise teams in Angular for years and is the instructor for our Modern Angular Masterclass. His assessment: Many teams adopt modern Angular without changing their underlying mindset. They’re just writing old Angular code using new syntax.

Signals are not an API update—they require a shift in thinking

Anyone who treats Signals as if they were just a better EventEmitter hasn’t yet fully embraced the real change in Angular.

To explain this, Manfred uses a catchy metaphor: Most developers think of their application as a script—do this, then that, and output this data. But Angular has stopped expecting scripts. Signals require data-flow-oriented thinking—inputs lead to data, data leads to more data, and the end result is what the user sees. The metaphor is more like a river, not a script.

Anyone who sees this merely as new syntax for building the same things as before remains stuck in the old paradigm and misses the point of modern Angular.

Ten years of Angular experience can actually be a hindrance today

Anyone who has been writing Angular for ten years has built up ten years’ worth of patterns—but tragically, some of them are now getting in the way.

Anyone learning Modern Angular with old patterns is setting themselves up for failure—that’s how Manfred explains it. Karma, Jasmin, imperative workarounds that were once necessary are no longer in the projects; they’re in people’s minds. Modern Angular deliberately leaves this legacy behind, and that requires an active decision, not just regarding the tools but also regarding the mindset.

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Testing was broken. Everyone knew it, but hardly anyone said it out loud

If you wanted to test Angular components properly, you had to know when Angular runs its change detection internally. That’s not a testing task—it’s forensic analysis.

With Vitest and Browser Mode, that’s changing. The component becomes a black box: you test what it exposes to the outside world, not how it works internally. Manfred calls this “component testing” to distinguish it from “unit testing”—a level more abstract, closer to what users actually experience. Karma and Jasmin are deprecated, and migration is due anyway—what results when you do it right is more than just a tool switch.

Store and Forms: reactivity as a guiding principle

Anyone who views SignalStore merely as a replacement for NgRx has yet to grasp its most important feature.

Manfred describes it as the headless version of the application—a complete cross-section of the application logic, detached from any UI layer. This is an architectural decision, far more than just an implementation detail. And then there’s Signal Forms. According to Manfred, it is “perhaps the most beautiful API Angular has ever produced”—the third forms API, built on years of grappling with the pain points of the first two. Forms are no longer treated as a special case of reactive data flow, but as a consistent part of it. Store and Forms together show just how far Angular has taken reactive logic.

When the LLM controls the application, the architecture determines the outcome

Agentic UI isn’t a future scenario; it’s an architectural decision that teams need to make right now.

Interestingly, this is where the SignalStore comes back into play. Manfred describes a scenario that many recognize as a vision: an AI sidecar that pops up when needed. The user chats, and the sidecar controls the application via client-side tool calling, connected to the store—routes, forms, and data. The Store, as a headless variant, is precisely the link that underpins this architecture.

What Manfred doesn’t leave out—and what I consider the most important part: What happens if the model does something unintended? Human-in-the-loop, validation mechanisms, scorers—you can’t do without them. These are design decisions that must be finalized before the first production rollout.

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

You don’t change paradigms on a whim

The path to modern Angular doesn’t lie in a list of APIs; it lies in a shift in mindset and, with it, a new way of working. Anyone who skips this step will, sooner or later, hit a wall—at the latest when a language model takes remote control of their application.

In his Angular workshop at iJS Conference London, Manfred brings you up to speed with where Angular is headed and shows you how to use it in practice. You’ll learn how to design a clean, reactive architecture with Signals and build lean state management using the new NgRx Signal Store. But it doesn’t stop there: You’ll also create an AI-powered assistant that understands your app, guides users, and generates dynamic UI. Check out the full conference program and workshop details here.

The post If You Want to Understand Modern Angular with Signals, You Have to Relearn Angular appeared first on International JavaScript Conference.

]]>
Remodel your TypeScript Code with Decorators https://javascript-conference.com/blog/remodel-typescript-code-with-decorators/ Wed, 25 Mar 2026 14:42:43 +0000 https://javascript-conference.com/?p=209817 Learn how to clean up your TypeScript code by declaring behaviors orthogonally, by walking through a series of before-and-after code examples where we use Decorators to reduce code weight and improve readability.

The post Remodel your TypeScript Code with Decorators appeared first on International JavaScript Conference.

]]>

TypeScript has a fantastic mechanism called Decorators for attaching behaviors in an orthogonal way. It’s most commonly used by framework developers, but application developers have a lot to gain from using them as well.

The purpose of the decorator is to attach useful functionality to our code in a declarative way that clearly communicates what’s going on without cluttering up the code.

Angular developers will recognize such decorators as @Component, which identifies a class as a Component, @Injectable, which registers a class with the Angular dependency injection engine, and @ViewChild, which provides a code reference to an element in the HTML view.

Note that TypeScript Decorators look syntactically similar to Annotations you may be familiar with from languages such as Java. The big difference is that Annotations are compile-time modifiers that provide metadata to the element they modify, while Decorators are run-time functions that can also wrap or transform existing code.

Use Cases

Rather than starting with the boring details of how to create decorators, let’s dig straight into some interesting use-cases where decorators can help you clean up your code and provide valuable functionality with minimal effort.

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Memoize

Memoization is a common code optimization technique that automatically caches results for every set of input parameters to a function for reuse. This works in situations where the same inputs will always produce the same output (stable), and the output is the only goal (no side effects or operations). A memoized function returns the cached result for every call after the first. This can be used for intensive calculations (the Fibonacci sequence is a common example), or expensive calls such as to a database or API.

Implementing memoization on a function can be just a bit messy and makes the original intent of the function slightly less clear. Below are two examples of memoization, contrasted against the extremely simple original functions.

// Database Example
class UserService {
  getUser(userId: string): User {
    return this.db.fetchUser(userId)
  }

  memoizedGetUser(userId: string, memo?: {[key: string]: User}): User {
    memo = memo ?? {}
    if (!memo[userId]) memo[userId] = this.db.fetchUser(userId)
    return memo[userId]
  }
}
const userService = new UserService()
const user = memoizedGetUser('XY43797')

// Fibonacci Example
class MathUtils {
  fibonacci(n: number): number {
    if(n < 0) throw new Error('Positive numbers only please')
    return n < 2 ? n : ( fibonacci(n - 1) + fibonacci(n - 2) )
  }

  memoizedFibonacci(n: number, memo?: {[key: number]: number}): number {
    if(n < 0) throw new Error('Positive numbers only please')
    memo = memo ?? {}
    if(memo[n]) return memo[n]
    return memo[n] = n < 2 ? n : (
      fibonacci(n - 1,  memo) + fibonacci(n - 2, memo)
    )
  }
}
const mathUtils = new MathUtils()
console.log('fibonacci', 5, mathUtils.memoizedFibonacci(5))
console.log('fibonacci', 12, mathUtils.memoizedFibonacci(12))

Using a @Memoize decorator, we can accomplish the same functionality with none of the complexity visible in our code.

@Memoize
getUser(userId: string): User {
  return this.db.fetchUser(userId)
}

@Memoize
fibonacci(n: number): number {
  if(n < 0) throw new Error('Positive numbers only please')
  return n < 2 ? n : ( fibonacci(n - 1) + fibonacci(n - 2) )
}

All of the complexity has been abstracted away into the implementation of the decorator. Before we get into these implementation details, let’s look at a few more examples.

Measure Performance

It can often be useful to measure the time it takes for a method to complete. This can help us find bugs, performance issues, or room for improvement through techniques such as Memoization.

The following example measures the total time taken to perform a function call and logs the results using Console Timers.

class UserService {
  fetchUser(email: string): Promise<User> {
    return this.db.query('Users', 'email', email)
  }

  fetchUserMeasureTime(email: string): Promise<User> {
    console.time('UserService#fetchUser') // Needs to be unique
    const result = db.query('Users', 'email', email)
    console.timeEnd('UserService#fetchUser')
    return result
  }
}

While sometimes we may want to leave this performance measuring code in production, other times we just want to be able to quickly add and remove it during testing. This is tricky when we may have to change the way the function returns, such as in the example above. Additionally, we must be careful to always use a unique value for the timer label to avoid errors or inaccurate results. We can handle these considerations with a @PerfLog decorator, which can be easily added anytime and is easily discoverable.

class UserService {
  @PerfLog
  fetchUser(email: string): Promise<User> {
    return this.db.query('Users', 'email', email)
  }
}

We can go even further and add configurable performance monitoring to an entire class. The Angular framework has a series of lifecycle hooks that application developers can use to respond to setup, update, and tear down events. Through a carefully crafted @AngularPerformance() class decorator, we can automatically add performance markers for these events to the browser’s performance data along with measurements of the component startup time.

import { Component } from '@angular/core';
import { AngularPerformance } from './angular-performance.decorator';
import { environment } from '../environments/environment.development';

@AngularPerformance(!environment.production)
@Component({
  selector: 'app-root',
  template: '<h1>{{title}}</h1>',
})
export class ExampleComponent {
  title = 'angular';
}

With just a single line of code (plus imports), we’ve added performance monitoring to all Angular lifecycle events for the class for all non-production environments, providing a tremendous amount of visibility into application behavior.

When doing a performance recording in Chrome, the lifecycle marks show up in the timing diagram and in the event listing as shown below.

Screenshot of the Performance tab in the Google Chrome developer tools, showing the Gantt chart of event timings above and a list of timing events in a list below

Figure 1: Screenshot of the Performance tab in the Google Chrome developer tools, showing the Gantt chart of event timings above and a list of timing events in a list below

Parameter Management

It’s very common for us to perform standard operations on function parameters, including and especially making sure that we handle missing or null parameters appropriately. Two common tasks include returning null if the parameters are missing and logging the values sent to a function for troubleshooting purposes. Without decorators, we could do this.

class RandomStuff {
  // Log parameter names & values
  sendMessage(fullName: string, email: string): Promise<boolean> {
    console.debug('RandomStuff#sendMessage', fullName, email)
    // Do stuff here
    return true
  }

  // If the parameter is null, return null
  function getUser(userId: string): User {
    if(userId === null) {
      return null
    }
    return this.db.fetchUser(userId)
  }
}

This isn’t hard to do, but it clutters up the code with things that aren’t directly related. It’s also a bit harder to make this configurable at the application level or to find the places we are (or should be) applying this behavior. We can accomplish the same thing with @LogParams(level) and @PassNull(match) method decorators, which accept configuration parameters to control behavior.

class RandomStuff {
  @LogParams('debug')
  sendMessage(fullName: string, email: string): Promise<boolean> {
    // Do stuff here
    return true
  }

  @PassNull()
  function getUser(userId: string): User {
    return this.db.fetchUser(userId)
  }
}

This allows our functions to maintain their cohesion while still adding the intended functionality.

Persistence

While memoization is great for functions that are called repeatedly while an application is running, sometimes we need persistence between application runs. One common use case for this is to maintain user preferences on their device, or to hold state in the browser (outside of the session) in case the user reloads the page. Without persistence, we might see this:

class UserService {
  public userId: string
}

const userService = new UserService()
userService.userId = 'XY43797'
console.log('User ID', userService.userId)

In this example, the userId property is publicly readable and writable, but I have no way to capture changes to this property to add persistence. Fortunately, TypeScript has us covered with Accessors, which are get and set methods for class properties.

class UserService {
  private _userId: string

  get userId() {
    return this._userId ?? (
      this._userId = localStorage.getItem('UserService_userId')
    )
  }

  set userId(id: string) { 
    localStorage.setItem('UserService_userId', this._userId = id)   
  }
}

const userService = new UserService()
userService.userId = 'XY43797'
console.log('User ID', userService.userId)

In the above example, you’ll notice that we read/write to the property the same way we did before, but behind the scenes, the get and set accessor functions are being called. This has allowed us to keep a localStorage property in sync with the class property _userId so that this value will be available between sessions. We can further simplify this example with the use of an accessor decorator.

class UserService {
  private _userId: string

  @Persist
  get userId() { return this._userId }
  set userId(id: string) { this._userId = id }
}

There are two possible surprises about the accessor decorator. The first is that we have to decorate an accessor instead of just decorating the property itself. The reason for this is that property decorators (which do exist) are unable to add behaviors or attach code. The second surprise is that we have just one decorator instead of decorating both get and set separately. The reason is that accessors are treated as a single unit, and you’ll actually get an error if you attempt to add the decorator to both accessors. You can decorate either get or set as you wish, and these functions don’t have to be adjacent in the code, although you’ll find your code much easier to read if you keep them together.

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Decorator Implementation

Now that we’ve seen some compelling examples of custom decorators, it’s time to show off the implementation. Note that we’re only using three types of decorators in this article: Method decorators, Class decorators, and Accessor decorators. TypeScript additionally supports Property and Parameter decorators.

Getting Started

Before you can jump right into the code, it’s important to note that Decorators are still currently an experimental feature that you must enable in your TypeScript configuration file.

{
  "compilerOptions": {
    "lib": ["es6"],
    "module": "commonjs",
    "experimentalDecorators": true,
    "target": "es6",
  }
}

The important entry in this example tsconfig.json file is the experimentalDecorators setting, which must be true for your decorators to work. It’s also important that the target be set to ECMAScript 6 or later, as shown here.

@Memoize

We’ll start by showing the implementation of the method decorator for memoize.

export function Memoize(
  target: any,
  methodName: string,
  descriptor: PropertyDescriptor
) {
  // function details
}

This is the standard signature for a method decorator function. The parameters are:

  • target – the object instance of the class containing the decorated method. This allows you to modify the underlying object
  • methodName – the name of the decorated method (for example, “getUser” or “fibonacci”)
  • descriptor – the metadata of the method being modified

Because this is a method decorator, the actual method we’re decorating can be found in descriptor.value, which should be treated as a function reference. We’ll replace this function with our own implementation so that we can modify it, but we have to make sure to call the original function, so the underlying functionality is unaltered.

const originalMethod = descriptor.value

  descriptor.value = function(...args: any[]) {
    originalMethod.apply(this, args)
  }

The next step is to set up our memo and define the key we’ll use to uniquely identify values in the memo based on the parameter values. It’s important that we account for multiple parameters.

const memo: { [key: string]: any } = {}
  descriptor.value = function(...args: any[]) {
    const _key = [target.constructor.name, methodName, ...args]
      .map(o => o.toString()).join('_')
  }

The memo has a string key and any type of object value, and the key is defined as a concatenation of the class name, the method name, and the argument values. The last step is to read from the memo whenever possible and update the memo with the value when necessary. This gives the following final implementation.

export function Memoize(
  target: any,
  methodName: string,
  descriptor: PropertyDescriptor
) {
  const memo: { [key: string]: any } = {}
  const originalMethod = descriptor.value
  descriptor.value = function(...args: any[]) {
    const _key = [target.constructor.name, methodName, ...args]
      .map(o => o.toString()).join('_')
    return memo[_key] ?? ( memo[_key] = originalMethod.apply(this, args) )
  }
}

PropertyDescriptor

Don’t let the type name PropertyDescriptor throw you off. Technically, all members of a class are “properties,” whether they are simple values, objects, functions, or accessors, so you’ll see this descriptor across all of our example decorators. The properties of the descriptor are all optional, and include:

  • configurable – a boolean value that indicates if the descriptor itself can be changed, such as changing writable or enumerable properties, or if the property can be deleted from its containing object.
  • enumerable – indicates if this property will be included in iterations over the object, such as for…in loops or Object.keys().
  • value – the actual value of the property, which may be data or a function.
  • writable – a boolean value that indicates if the property can be changed. Setting this to false will cause future reassignment attempts to be ignored in non-strict mode or to throw an error in strict mode.
  • get – the actual property getter function for accessor descriptors. When the property is accessed, this function is called, and its return value becomes the property’s value.
  • set – the actual property setter function for accessor descriptors. When the property is assigned a new value, this function is called with the new value as an argument.

@PerfLog

Similar to the Memoize decorator, @PerfLog is a simple method descriptor that we’ll use to inject timing calls before and after the original function does its thing.

const _context = `${target.constructor.name}_${methodName}`

  const originalMethod = descriptor.value

  descriptor.value = function(...args: any[]) {
    const _key = `${_context}_${globalThis.performance.now()}`
    console.time(_key)
    const retVal = originalMethod.apply(this, ...args)
    console.timeEnd(_key)
    return retVal
  }

We start by setting context, which includes the class name and method name, because console timers require unique values. We then account for multiple calls to the same function by adding a high-performance timer value to the key we use for the timer to ensure that every single call to the decorated function will have its own unique timer. Otherwise, we’re overriding the original function, adding a timer call before and after the method, and then returning any value from the original method.

This works well in many cases, but most of the time we want to time a function, there will be a Promise involved. We don’t actually want to measure how long it takes to return a Promise, but how long it takes for the promise to complete once all the work is done. This requires some extra code.

 if(retVal && typeof (retVal as PromiseLike<any>).then === 'function') {
      return (retVal as PromiseLike<any>).then(value => {
        console.timeEnd(_key)
        return value
      })
    }

This does a type check against the return value to see if it’s a promise that we need to wait for, in which case we call the end timer once the promise is complete. Putting this all together gives the final implementation for @PerfLog.

export function PerfLog(
  target: any,
  methodName: string,
  descriptor: PropertyDescriptor
) {
  const _context = `${target.constructor.name}_${methodName}`
  const originalMethod = descriptor.value

  descriptor.value = function(...args: any[]) {
    const _key = `${_context}_${globalThis.performance.now()}`
    console.time(_key)
    const retVal = originalMethod.apply(this, ...args)
    if(retVal && typeof (retVal as PromiseLike<any>).then === 'function') {
      return (retVal as PromiseLike<any>).then(value => {
        console.timeEnd(_key)
        return value
      })
    }
    console.timeEnd(_key)
    return retVal
  }
}

@LogParams(level)

Unlike our previous decorators, we want to provide configuration to the @LogParams decorator to indicate the logging level (debug, info, etc.) that we want to use for reporting the parameters and values. This will require us to use a Decorator Factory.

export function LogParams(
  level: 'debug' | 'info' | 'warn' | 'error' = 'debug'
): MethodDecorator {
  return (
    target: any,
    methodName: string,
    descriptor: PropertyDescriptor
  ) => {
    // implementation here
  }
}

The decorator factory takes a single optional parameter of level, which we default to “debug”, making this the value if the decorator is used without a parameter, such as @LogParams(). Note that the use of parentheses is not optional for decorator factories. The factory returns a MethodDecorator which has the same function signature we’re familiar with.

The logging of parameters itself is very straightforward, and we use the provided level as a parameter to the console. Note that we’re handling the case where no arguments are provided to the function call.

if(args.length) console[level](`${methodName} Params`, ...args)
      else console[level](`${methodName} Params void`)

@PassNull(match)

We’ll also implement @PassNull as a decorator factory so that we can accept a match parameter of “any” or “all”. This specifies if we want to return null automatically when any one of the method parameters is null, or only if every one of the method parameters is null.

export function PassNull(match: 'any' | 'all' = 'any'): MethodDecorator {
  return (
    target: any,
    methodName: string,
    descriptor: TypedPropertyDescriptor<any>
  ) => {
    const originalMethod = descriptor.value

    descriptor.value = function(...args: any[]) {
      switch(match) {
        case 'all':
          if(args?.length && args.every(arg => arg === null)) return null
          break
        case 'any':
          if(args?.length && args.some(arg => arg === null)) return null
          break
      }

      return originalMethod.apply(this, args)
    }
  }
}

Let’s look at how this behaves in an example context.

class UserService {
  @PassNull('all')
  findUser(id: string, email: string) {
    // do stuff
  }

  @PassNull() // any
  changeEmail(oldEmail: string, newEmail: string) {
    // do stuff
  }
}

const userService = new UserService()

userService.findUser(null, '[email protected]') // ok
userService.findUser('XY43797', null)          // ok
userService.findUser(null, null)               // null

userService.changeEmail('[email protected]', '[email protected]') // ok
userService.changeEmail(null, [email protected]')                // null
userService.changeEmail('[email protected]', null)                  // null

@Persist

As explained above, we’ll implement persistence with an Accessor Decorator using localStorage. While local storage is a Web API, there are implementations available for Node.js that would allow this to run across environments. We’ll get right to it, since no factory is needed and the method signature looks familiar.

export function Persist(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const _key = `${target.constructor.name}/Persist/${propertyKey}`

  const originalGetter = descriptor.get
  descriptor.get = function () {
    const prop = originalGetter.call(this)
    return originalGetter.call(this) ?? globalThis.localStorage.getItem(_key)
  }

  const originalSetter = descriptor.set
  descriptor.set = function(value: any) {
    globalThis.localStorage.setItem(_key, value)
    originalSetter.call(this, value)
  }
}

We’re using globalThis, so the example code can be run and tested in a Node.js environment, but in a web environment, this will be effectively synonymous with window. As we’ve done with method decorators, we’re wrapping the existing functionality, but rather than descriptor.value we’re using .get and .set to obtain the accessor functions.

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

@AngularPerformance(enabled)

Before digging into the promise of tracking performance of Angular lifecycle events, let’s take a look at what those events are:

  • constructor – when Angular instantiates the component
  • ngOnInit – once all inputs have been initialized
  • ngAfterContentInit – after the component’s content has been initialized
  • ngAfterViewInit – after the component’s view has been initialized
  • ngOnChanges – every time inputs have changed
  • ngDoCheck – every time this component is checked for changes
  • ngAfterContentChecked – every time the content has been checked for changes
  • ngAfterViewChecked – every time the view has been checked for changes

There are additional events for rendering and destruction of the component, but we’ll only be tracking the above events for this example. Read more about these lifecycle events here.

To do everything we’ve promised, we’ll start with a Class Decorator Factory and our own custom interface for convenience.

export function AngularPerformance(enabled: boolean = true) {
  return function _ClassDecorator<T extends NgClassConstructor>(target: T) {
    if(enabled) {
      // setup event hooks
    }
    return target
  }
}

interface NgClassConstructor {
  new(...args: any[]): {}
}

The enabled parameter defaults to true, allowing us to apply this decorator as simply @AngularPerformance(), but this allows us to conditionally disable this decorator.

Unlike the @PerfLog decorator, here we’ll be using the Performance API instead of the Console API. The requirements are similar: provide a unique name with every call. We can use mark for point-in-time snapshots and measure to calculate a range of time between marks. Because setting up the tracking will be the same for every event hook, and because it’s quite tedious, we’ll abstract this into its own function.

function setupMethod(constructor: any, methodName: string) {
  const className = constructor.name
  const original = constructor.prototype[methodName]
  let index = 0

  constructor.prototype[methodName] = function(...args: any) {
    const _key = `${className}_${methodName}_${index++}`
    globalThis.performance.mark(_key)
    if(original) {
      original.apply(this, args)
    }
  }
}

Conveniently, the lifecycle hooks are void functions, so we don’t need to worry about return values. Note that we’re building a unique key using the class name, method name, and an incrementing index, so each call gets its own mark.

NOTE: this code never cleans up after itself by calling clearMarks, so it can absolutely cause memory leaks and as-is should never be left running for extended periods of time.

We can use this to finish setting up event hooks.

      setupMethod(target, 'ngOnInit')
      setupMethod(target, 'ngAfterContentInit')
      setupMethod(target, 'ngAfterViewInit')
      setupMethod(target, 'ngOnChanges')
      setupMethod(target, 'ngDoCheck')
      setupMethod(target, 'ngAfterContentChecked')
      setupMethod(target, 'ngAfterViewChecked')

This would be good enough for many uses, but we want to additionally measure various startup times for the component.

  • init: component → ngOnInit
  • contentInit: ngOnInit → afterContentInit
  • viewInit: ngOnInit → afterViewInit

These measurements will show up in the browser performance graph and will help us understand the startup timing of the component. Unfortunately, this introduces a new wrinkle because we need consistent names for the marks we’re using to measure between. We’ll start by setting up the model and configuration.

export function AngularPerformance(enabled: boolean = true) {
  return function _ClassDecorator<T extends NgClassConstructor>(target: T) {
    if(enabled) {
      setupMethod(target, 'ngOnInit',
        { measurementName: 'init', markStart: 'constructor' })
      setupMethod(target, 'ngAfterContentInit',
        { measurementName: 'contentInit', markStart: 'ngOnInit' })
      setupMethod(target, 'ngAfterViewInit',
        { measurementName: 'viewInit', markStart: 'ngOnInit' })
      // no change to other event hooks
      globalThis.performance.mark(`${target.name}_constructor`)
    }
    return target
  }
}

type Measure = {
  measurementName: string
  markStart: string
}

function setupMethod(
  constructor: any,
  methodName: string,
  measurement?: Measure
) {
  constructor.prototype[methodName] = function(...args: any) {
    if(measurement) {
      // Handle measurements
    }
    // original code
  }
}

The new Measure type gives us a parameter to use for specifying what to measure. In addition to specifying the start and end points of the measurement, we need to create a mark for the constructor that we can measure from. In most contexts, we could wrap the constructor function to add this mark at either the beginning or the end of the constructor function, but this is not compatible with Angular due to the dependency injection that takes place. We’ll use the configuration of the decorator itself as a reasonable timing surrogate, as we can reasonably expect this to run just before the component is constructed.

With all of this configuration work done, now we need to perform the measurements.

      const perf = globalThis.performance
      const _start = `${className}_${measurement.markStart}`
      const _end = `${className}_${methodName}`
      perf.mark(_end)
      perf.measure(
        `${className}_${measurement.measurementName}_${index++}`,
        _start,
        _end,
      )

In each case, the end point of the measurement is the function being called, which allows us to specify the endpoint. Technically, marking this is a duplication since we already have another mark at this point, but it’s necessary so that we have a non-indexed mark that can be referenced by the next measurement. With this, we can put it all together for the final @AngularPerformance(enabled) implementation.

export function AngularPerformance(enabled: boolean = true) {
  return function _ClassDecorator<T extends NgClassConstructor>(target: T) {
    if(enabled) {
      setupMethod(target, 'ngOnInit',
        { measurementName: 'init', markStart: 'constructor' })
      setupMethod(target, 'ngAfterContentInit',
        { measurementName: 'contentInit', markStart: 'ngOnInit' })
      setupMethod(target, 'ngAfterViewInit',
        { measurementName: 'viewInit', markStart: 'ngOnInit' })
      setupMethod(target, 'ngOnChanges')
      setupMethod(target, 'ngDoCheck')
      setupMethod(target, 'ngAfterContentChecked')
      setupMethod(target, 'ngAfterViewChecked')
      globalThis.performance.mark(`${target.name}_constructor`)
    }
    return target
  }
}

interface NgClassConstructor {
  new(...args: any[]): {}
}

type Measure = {
  measurementName: string // measurement name
  markStart: string // method name
}

function setupMethod(
  constructor: any,
  methodName: string,
  measurement?: Measure
) {
  const perf = globalThis.performance
  const className = constructor.name
  const original = constructor.prototype[methodName]
  let index = 0

  constructor.prototype[methodName] = function(...args: any) {
    if(measurement) {
      const _start = `${className}_${measurement.markStart}`
      const _end = `${className}_${methodName}`
      perf.mark(_end)
      if(!perf.getEntriesByName(_start, 'mark')?.length){
        console.warn('Missing starting performance mark', _start)
        return
      } else if(!perf.getEntriesByName(_end, 'mark')?.length) {
        console.warn('Missing ending performance mark', _end)
        return
      }
      perf.measure(
        `${className}_${measurement.measurementName}_${index++}`,
        _start,
        _end,
      )
    }
    const _key = `${className}_${methodName}_${index++}`
    perf.mark(_key)
    if(original) {
      original.apply(this, args)
    }
  }
}

It’s important to note that this implementation will track lifecycle events even if we don’t have those events implemented within the Angular class, making it particularly useful compared to the longhand method of implementing this type of timing class-by-class and method-by-method.

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Conclusion

We’ve introduced TypeScript decorators, including getting started with instructions and concrete code examples. We’ve explored six unique use cases where decorators can remodel our code for a better living experience, and we’ve shown example code both with and without the decorators for emphasis. Please check out the full source code at GitHub and reach out to me here with any questions and to share your experience implementing these examples. Enjoy decorating!

The post Remodel your TypeScript Code with Decorators appeared first on International JavaScript Conference.

]]>
Vitest: The New Default Testing Solution in Angular https://javascript-conference.com/blog/angular-21-vitest-testing/ Tue, 10 Mar 2026 15:13:53 +0000 https://javascript-conference.com/?p=209791 With Angular 21, Vitest officially replaces Karma as the default testing framework, marking a major shift toward faster, modern, and Vite-powered testing. Let’s explore why the Angular team made the switch, how it improves the developer experience, and what you need to know to migrate and take full advantage of the new setup.

The post Vitest: The New Default Testing Solution in Angular appeared first on International JavaScript Conference.

]]>
Introduction: The End of the Waiting Game

For over a decade, Angular has held a unique and sometimes contentious position in the frontend ecosystem. Unlike many competitors that treated testing as an optional add-on, Angular baked it into the core platform’s DNA. Since the release of Angular 2, Karma has been the faithful engine driving this philosophy. It provided stability in an era of browser fragmentation, ensuring that enterprise code ran correctly in “wild” environments like Internet Explorer 9, Chrome, and Firefox.

However, the landscape of web development has shifted dramatically. The rise of meta-frameworks, server-side rendering, and instant-feedback tooling has rendered the heavy, browser-based approach of Karma increasingly obsolete. Developers today do not just want stability; they demand speed. The friction of waiting 30+ seconds for a test suite to boot up has become a tax on productivity that modern teams are no longer willing to pay.

With the release of Angular 21, the framework officially adopts Vitest as the default unit testing solution. This is not merely a swap of libraries (like replacing Moment.js with date-fns); it is a fundamental architectural paradigm shift in how Angular applications are compiled, served, and validated. By leveraging the power of Vite’s unbundled development server, Angular 21 offers a testing experience that is orders of magnitude faster and significantly more capable than its predecessor.

In this comprehensive guide, we will dissect the architectural differences between Karma and Vitest, provide a robust migration strategy, explore how to test modern Angular features like Signals and Effects, and discuss how this shift radically optimizes CI/CD pipelines.

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Part 1: The Architecture of Slowness (and Why Karma Had to Go)

To truly appreciate the future, we must understand the mechanical limitations of the past. Karma was built for a different internet. In 2012, browser inconsistency was the primary enemy of the web developer. A test passing in Chrome might fail in Safari or IE8 due to non-standard DOM implementations. Therefore, Karma’s architecture was designed to spin up real browser instances and execute tests inside them.

The Karma Bottleneck

Karma operates on a complex client-server model that introduces latency at every step. When you run ng test in a legacy Angular project, the following sequence occurs:

  1. Webpack Compilation: Your entire application (or massive chunks of it) is bundled into JavaScript files. This is the critical bottleneck.
  2. Server Start: Karma starts a local web server.
  3. Browser Launch: It launches a real browser process (Chrome/Firefox) and “captures” it.
  4. Execution: The browser downloads the heavy bundle and executes the tests.
  5. Reporting: Results are serialized and sent back to the terminal via a socket connection.

The pain point is the bundling phase. Every time you save a single spec file, Webpack often recompiles the entire dependency graph. As applications grow, this feedback loop extends from milliseconds to seconds and eventually to minutes. In large enterprise monorepos, a simple “Cmd+S” can trigger a 45-second wait before the developer knows whether a test passed.

The Vitest Paradigm Shift

Vitest abandons the “real browser by default” approach in favor of speed and modern standards. It is built on top of Vite, a build tool that serves source code over native ES Modules (ESM).

When you run ng test in Angular 21:

  • Instant Server Start: Vitest starts a Node.js process almost instantly.
  • On-Demand Compilation: It does not bundle your app. It compiles only the specific files imported by your test file, on request.
  • Smart Invalidation: It relies on Vite’s module graph to ensure only tests affected by your changes are rerun.
  • Headless Execution: Tests run in a lightweight headless environment (JSDOM or happy-dom) that simulates browser APIs without the overhead of a graphical UI.

This architecture removes the overhead of browser startup and full-app bundling, resulting in a Hot Module Replacement (HMR) style feedback loop for testing. You save the file, and the test result appears instantly.

Feature Karma (Legacy) Vitest (Modern)
Execution Environment Real Browser (Chrome/Firefox) Node.js (via JSDOM/HappyDOM)
Compilation Strategy Full Bundle (Webpack) On-demand (Vite/ESBuild)
Watch Mode Speed Slow (re-bundles on change) Instant (HMR-like)
Parallelization Limited (requires sharding) Native Worker Threads
Debugging Browser DevTools VS Code / Vitest UI

Part 2: The Angular 21 Default Experience

In Angular 21, the CLI simplifies the testing setup significantly. When generating a new project (ng new my-app), the complex karma.conf.js is replaced by a sleek vitest.config.ts.

The Configuration File

Angular’s implementation of Vitest relies on a builder that abstracts away much of the boilerplate, but the configuration remains accessible and extensible.

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import angular from '@analogjs/vite-plugin-angular'; // or official angular plugin

export default defineConfig({
  plugins: [angular()],
  test: {
    // Simulates a browser environment (window, document, etc.)
    environment: 'jsdom',
    // Allows using 'describe', 'it', and 'expect' globally without imports
    globals: true,
    // The setup file initializes the Angular testing environment
    setupFiles: ['./src/test-setup.ts'],
    // Only include spec files to avoid confusion with source files
    include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
    // Threading settings for performance
    pool: 'threads',
    poolOptions: {
      threads: {
        singleThread: false, // Set to true for debugging complex issues
      }
    },
    // Clean up mocks automatically to prevent leaks across test files
    restoreMocks: true,
    reporters: ['default', 'html'], // 'html' generates a visual report
  },
});

The Test Setup

The connection between Angular’s Dependency Injection system and Vitest happens in test-setup.ts. This file replaces test.ts from the Karma era.

// src/test-setup.ts
import '@angular/localize/init'; // Required for i18n support
import 'zone.js/testing';        // Essential for Angular's async detection
import { getTestBed } from '@angular/core/testing';
import {
  BrowserDynamicTestingModule,
  platformBrowserDynamicTesting,
} from '@angular/platform-browser-dynamic/testing';

// Initialize the Angular testing environment
getTestBed().initTestEnvironment(
  BrowserDynamicTestingModule,
  platformBrowserDynamicTesting()
);

Key Insight: Even though Vitest runs in Node, we still import zone.js/testing. This is crucial because Angular’s change detection relies on Zones. This ensures utilities like fakeAsynctick, and flush continue to work exactly as they did in Karma, easing the transition.

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Part 3: Migration Guide – From Karma to Vitest

Migrating an existing application is less daunting than it appears. The syntax for writing tests in Angular has always been abstracted by the TestBed API, which remains unchanged. The migration is primarily infrastructural.

Step 1: Clean House

First, remove the legacy dependencies. This reduces node_modules bloat and prevents configuration conflicts.

npm uninstall karma karma-chrome-launcher karma-coverage karma-jasmine \
karma-jasmine-html-reporter jasmine-core @types/jasmine

Step 2: Install Vitest Ecosystem

You will need Vitest, the UI library (highly recommended for debugging), and the JSDOM environment.

npm install –save-dev vitest @vitest/ui jsdom @analogjs/vite-plugin-angular

Step 3: Global Types and Compilation

One of the most common friction points is the clash between Jasmine types (which Karma used) and Vitest types (Jest-compatible). You need to explicitly tell TypeScript which types to load.

Update your tsconfig.spec.json:

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/spec",
    "types": [
      "vitest/globals",
      "node" // Required for JSDOM access
    ]
  },
  "include": [
    "src/**/*.spec.ts",
    "src/**/*.d.ts"
  ]
}

Step 4: Refactoring Spies and Mocks

This is the only area where code changes are frequent. While Vitest supports Jasmine-style syntax in many cases, its native spying utility (vi) is more powerful.

The “Spy” Shift:

  • Karma/Jasmine: spyOn(obj, ‘method’).and.returnValue(…)
  • Vitest: vi.spyOn(obj, ‘method’).mockReturnValue(…)

Example Migration:

// LEGACY (Jasmine)
spyOn(authService, 'login').and.returnValue(of(true));
expect(authService.login).toHaveBeenCalledWith('user', 'pass');

// MODERN (Vitest)
import { vi } from 'vitest';

const loginSpy = vi.spyOn(authService, 'login');
loginSpy.mockReturnValue(of(true));
expect(authService.login).toHaveBeenCalledWith('user', 'pass');

Part 4: Advanced Capabilities & Modern Testing Strategies

Moving to Vitest isn’t just about parity; it’s about gaining capabilities that were difficult or painful with Karma.

1. Snapshot Testing

Snapshot testing captures rendered DOM output and stores it in a file. If the HTML structure changes unexpectedly, the test fails. This is invaluable for dumb/presentational components to prevent UI regressions.

it('should render the dashboard layout correctly', () => {
  const fixture = TestBed.createComponent(DashboardComponent);
  fixture.detectChanges();
  // Serializes the HTML and compares it to the stored snapshot
  expect(fixture.nativeElement).toMatchSnapshot();
});

2. Testing Signals and Effects

Angular 21 relies heavily on Signals. Because Signals are synchronous by nature, tests often become simpler. However, testing Effects requires a trick because they run asynchronously during the change detection cycle.

it('should update the computed signal when input changes', () => {
  const component = fixture.componentInstance;
  // Set signal directly
  component.quantity.set(5);
  
  // Trigger change detection to update computed signals
  fixture.detectChanges(); 
  
  expect(component.totalPrice()).toBe(50);
});

it('should trigger an effect', async () => {
  const logSpy = vi.spyOn(console, 'log');
  const component = fixture.componentInstance;
  
  component.userId.set('123');
  fixture.detectChanges();
  
  // Effects run asynchronously; wait for them to settle
  await fixture.whenStable();
  
  expect(logSpy).toHaveBeenCalledWith('User ID changed to 123');
});

3. Modern HTTP Testing

Testing Services that make HTTP calls are a staple of Angular apps. With Vitest, you combine HttpTestingController with standard expectations.

import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';

describe('UserService', () => {
  let service: UserService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        UserService,
        provideHttpClient(),
        provideHttpClientTesting(),
      ]
    });
    service = TestBed.inject(UserService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  it('should fetch user data', () => {
    service.getUser('1').subscribe(user => {
      expect(user.name).toBe('Alice');
    });

    const req = httpMock.expectOne('/api/user/1');
    expect(req.request.method).toBe('GET');
    req.flush({ name: 'Alice' });
  });
});

4. In-Source Testing

Vitest allows tests to live inside source files. This is perfect for pure utility functions where creating a dedicated .spec.ts file adds file-tree clutter.

// src/app/utils/math.ts
export function calculateTax(amount: number): number {
  return amount * 0.2;
}

// This block is stripped out during production builds
if (import.meta.vitest) {
  const { it, expect } = import.meta.vitest;
  it('calculates tax correctly', () => {
    expect(calculateTax(100)).toBe(20);
  });
}

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Part 5: Developer Experience (DX) and Tooling

The biggest upgrade in Angular 21 isn’t just raw speed; it’s the Developer Experience.

Vitest UI

Vitest comes with an optional UI that visualizes your test suite. Run: npx vitest –ui

This launches a web dashboard where you can:

  • View the module graph to see which files are testing what.
  • See a real-time log of console output for specific tests.
  • Visually debug: Click on a test to see the code and error stack trace side-by-side.

VS Code Integration

The Vitest VS Code extension is a game-changer. It puts “Run” and “Debug” buttons directly next to your it blocks in the editor.

  • Debugging: You can set a breakpoint in your TypeScript code, right-click the test icon in the gutter, and select “Debug Test.” Because Vitest runs in Node, the debugger attaches instantly, with no more complex Chrome remote debugging setups.

Part 6: CI/CD Integration and Performance

One of Karma’s hidden costs was CI execution time. Running headless Chrome in Docker containers consumes significant memory and CPU, often leading to flaky timeouts.

Vitest improves CI in three major ways:

  1. V8 Coverage: Vitest uses native V8 code coverage (the engine inside Node/Chrome) rather than instrumenting code with Babel/Istanbul. This makes generating coverage reports nearly free in terms of performance.

npx vitest run –coverage

  1. Concurrency: Vitest runs test files in parallel using worker threads by default. If you have a 16-core CI runner, Vitest will utilize all cores to crunch through tests.
  2. No Browser Dependencies: You no longer need to install Chrome or configure puppeteer in your Docker images. A standard Node.js container is all you need.

Performance Benchmark (Real-World Example):

  • Project: Medium-sized Monorepo (~5,000 tests)
  • Karma Execution: ~4 minutes (plus flaky browser disconnects)
  • Vitest Execution: ~45 seconds

Even when exact numbers vary, the direction is consistent: faster pipelines, shorter feedback cycles, and higher confidence in merges.

Part 7: The “Gotchas”: JSDOM vs. Real Browser

The biggest mental shift for Angular developers is accepting that JSDOM is not a full browser. It is a JavaScript implementation of browser APIs running inside Node.js.

Limitations

  • Rendering: JSDOM does not paint pixels. getBoundingClientRect()offsetWidth, and innerHeight will often return 0 or defaults.
  • Missing APIs: Features like ResizeObserverIntersectionObserverCanvas, and WebGL are not present by default.

The Solution: Mocking

If your component relies on these APIs, you must mock them in test-setup.ts.

// Mocking matchMedia
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: (query: string) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: () => {}, // Deprecated
    removeListener: () => {}, // Deprecated
    addEventListener: () => {},
    removeEventListener: () => {},
    dispatchEvent: () => {},
  }),
});

// Mocking ResizeObserver
global.ResizeObserver = class ResizeObserver {
  observe() {}
  unobserve() {}
  disconnect() {}
};

Strategic Advice: If a test strictly requires layout calculation (e.g., “does this dropdown fit on the screen?”), that test belongs in End-to-End (E2E) testing with Playwright or Cypress, not in unit tests. Vitest covers logic; Playwright covers rendering.

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Conclusion: Embracing the Future

The transition from Karma to Vitest in Angular 21 is a clear statement: Angular is committed to modern tooling and fast feedback loops. By adopting ecosystem-standard tools, Angular becomes more approachable for developers coming from React or Vue and significantly more enjoyable for long-time Angular teams maintaining large codebases.

The benefits are immediate. Speed keeps developers in the “flow state.” Debugging improves thanks to clearer error reporting and modern UI tools. Configuration shrinks, and the entire toolchain aligns better with modern web standards.

Migration requires attention to detail (specifically regarding test types and browser API mocking), but the payoff is a testing suite that feels like an asset rather than a burden. Vitest positions Angular applications to be faster, leaner, and ready for whatever the next generation of web development brings.

The post Vitest: The New Default Testing Solution in Angular appeared first on International JavaScript Conference.

]]>
Modern Angular Is Easier Than React https://javascript-conference.com/blog/angular-vs-react-modern-frameworks/ Tue, 20 Jan 2026 21:38:41 +0000 https://javascript-conference.com/?p=108653 Modern Angular has reached a level of clarity and ergonomics that would have been unthinkable just a few years ago. With the introduction of Signals, the progressive removal of Zones, a modern DI system based on standalone components, and an increasingly intuitive mental model, Angular has quietly evolved into a framework that often feels simpler than React in real-world applications. This article provides an in-depth and neutral analysis of why Angular’s modern architecture reduces cognitive overhead, eliminates entire classes of bugs, and offers a more predictable developer experience even for projects that scale over time.

The post Modern Angular Is Easier Than React appeared first on International JavaScript Conference.

]]>
Through concrete examples, we compare the React and Angular mental models around reactivity, rendering, state propagation, component design, and side-effects management. We examine why React’s freedom-first approach frequently leads to fragmentation, while Angular’s built-in conventions result in faster onboarding, fewer architectural decisions, and a more consistent codebase for teams.

The goal is not to declare a winner, but to show how and why modern Angular has become surprisingly easy, elegant, and productive. Developers familiar with React will discover an alternative perspective on simplicity, and Angular developers will gain a clearer understanding of the design principles behind the framework’s recent evolution.

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Introduction

For more than a decade, the frontend ecosystem has been defined by the perceived rivalry between React and Angular. The comparison itself, however, has changed dramatically in recent years. React has continued to evolve through flexibility and ecosystem diversity, while Angular has undergone a deep transformation that has simplified its core model and elevated the overall developer experience.

Today’s Angular is not the Angular that many developers remember. Over the last three years, the framework has progressively removed architectural friction, streamlined its mental model, introduced intuitive reactive primitives, and reduced boilerplate to a minimum. This shift has gone largely unnoticed by those who stopped evaluating Angular after its earlier iterations.

As a result, many React developers are genuinely surprised when they revisit modern Angular. They discover a framework that is far more approachable, much easier to reason about, and dramatically more productive than expected, especially on real-world projects where clarity, predictability, and consistency matter more than isolated benchmark comparisons.

What used to be a rivalry based on assumptions is now a conversation rooted in reality and first-hand experience.

Why Angular Used to Feel Hard

For many developers, Angular earned a reputation for being complex long before its recent evolution. This perception was not unfounded. Early versions of the framework introduced a substantial amount of architectural structure upfront, requiring developers to understand modules, DI hierarchies, lifecycle abstractions, form architectures, change detection mechanics, and a tightly integrated CLI ecosystem before they could feel productive.

The initial learning curve was steep, and the framework often seemed to demand more decisions and more boilerplate than alternatives. Concepts such as NgModules, decorators, metadata configuration, and dependency graphs could feel intimidating, especially to teams arriving from lighter stacks or from the React ecosystem, where the entry surface is intentionally minimal.

Moreover, the presence of Zones as an underlying runtime mechanism added an invisible layer of complexity: developers had to trust a system they could not directly see, debug, or influence easily. Combined with the weight of enterprise expectations, Angular often appeared rigid, verbose, and difficult to grasp without extensive onboarding.

This historical memory persisted for years. Even as the framework matured, many developers maintained an internalized image of Angular as a demanding platform with a high cognitive entry cost, a perception that modern Angular is now actively dismantling.

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Even for something as simple as a button with an internal state, the framework required:

  • a component class
  • a template
  • metadata configuration
  • a module wrapper
// Angular (legacy mental model)
import { Component, NgModule } from '@angular/core'

@Component({
  selector: 'counter',
  template: `
    <button (click)="increment()">Count: {{ count }}</button>
  `
})
export class CounterComponent {
  count = 0
  increment() {
    this.count++
  }
}

@NgModule({
  declarations: [CounterComponent],
  exports: [CounterComponent]
})
export class CounterModule {}

The equivalent in React during the same era was something like:

// React
export default function Counter() {
  const [count, setCount] = useState(0)

  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  )
}

React delivered a much lighter mental model for the same result, reinforcing the perception that Angular was more demanding than it needed to be.

The Turning Point: Signals, Standalone APIs, and a Radically Simpler Architecture

Over the last three years, Angular has experienced a profound transformation, arguably the most significant in its history. What was once perceived as a complex enterprise framework has become streamlined, intuitive, and remarkably lean. This evolution is not cosmetic. It is architectural.

The introduction of Standalone Components marked the beginning of a cultural shift inside the framework. For the first time, Angular allowed developers to build without NgModules, dramatically reducing boilerplate and making the development experience more direct and predictable. The mental model became clearer: a component is now the primary unit of behavior, not a structural wrapper.

The second turning point arrived with Signals, a new reactive paradigm designed into the core of Angular. Rather than relying on invisible reactivity or layered abstractions, Signals expose a clear, explicit, synchronous flow of data and rendering. Developers can finally see reactivity as it happens, reason about it, and control it without guesswork. The framework’s entire change detection strategy is shifting toward a more understandable and deterministic model.

Meanwhile, the gradual move toward a zoneless runtime, alongside ergonomic improvements in routing, forms, server-side rendering, and hydration, has erased many of Angular’s historical pain points.

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Taken together, these changes represent a decisive redesign of Angular’s developer experience.
Not a patch, not an incremental fix, a reframed identity.

Modern Angular is no longer the heavy framework developers remember. It is fast, intentional, elegant, and surprisingly easy to adopt.

// Angular (modern)
import { Component, signal } from '@angular/core'

@Component({
  selector: 'app-counter',
  standalone: true,
  template: `
    <button (click)="count.set(count() + 1)">
      Count: {{ count() }}
    </button>
  `
})
export class Counter {
  count = signal(0)
}

What suddenly disappears:

  • no module ceremony
  • no complex metadata structure
  • no implicit runtime assumptions
  • no multi-level configuration

What remains is clarity.

The Reactivity Paradigm: React Mental Model Vs Angular Mental Model

The most significant divergence between the two frameworks lies in how they handle change. For years, React’s model was considered the gold standard of simplicity: UI is a function of state. When state changes, you run the function again to get the new UI.

However, as applications scale, this model reveals a hidden cost.

React: The “Re-render Everything” Trap

In React, a state change triggers a re-execution of the entire component function and, by default, its children. While the Virtual DOM ensures that only necessary DOM updates occur, the JavaScript thread is still busy re-evaluating expressions, recreating closures, and checking dependency arrays.

To prevent performance bottlenecks, React developers must manually intervene using what we might call “opt-in optimization.”

Consider a scenario where we have a derived value, a list of expensive calculations based on a filter. In React, to prevent this calculation from running on every render (even when unrelated state changes), developers must use useMemo. To prevent child components from re-rendering unnecessarily, they must use React.memo and useCallback.

Suddenly, the code is cluttered with “rendering logic” rather than “business logic.”

function Dashboard({ data, theme }) {
  // 1. Manual optimization required
  const filteredData = useMemo(() => {
    return expensiveFilter(data);
  }, [data]); // 2. The dependency array risk

  // 3. Function identity instability
  const handleSelect = useCallback((id) => {
     // ...
  }, []); 

  return (
    <div className={theme}>
      <List items={filteredData} onSelect={handleSelect} />
    </div>
  );
}

The cognitive load here is high. The developer is constantly asking: Will this cause an infinite loop? Did I miss a dependency? Is this object reference stable?

Nuance Note: The React Compiler

It is important to note that the React team is actively addressing the manual memoization burden with the React Compiler.

This build-time tool automates the usage of useMemo and useCallback, effectively shifting the optimization burden from the developer to the build pipeline. While this is a massive quality-of-life improvement that removes boilerplate, it does not change the fundamental execution model.

Under the hood, React still re-renders components. The compiler simply ensures those re-renders are efficient. In contrast, Angular Signals offer a different paradigm entirely: they allow the framework to bypass component re-evaluation altogether, updating only the specific DOM nodes that changed.

Angular: The Simplicity of Signals

Modern Angular flips this model. With the introduction of Signals, Angular adopts a fine-grained reactivity model (inspired by SolidJS).

In this model, components do not “re-render” in the React sense. Instead, the application consists of a dependency graph. When a Signal is updated, only the specific values dependent on that Signal are notified. If a Signal changes but the result of a computed value remains the same, the update stops there. It is precise, glitch-free, and local.

@Component({
  standalone: true,
  template: `
    <div [class]="theme()">
      <app-list [items]="filteredData()" (select)="handleSelect($event)" />
    </div>
  `
})
export class Dashboard {
  data = input.required<Item[]>();
  theme = input('light');

  // 1. Automatic optimization
  filteredData = computed(() => {
    return expensiveFilter(this.data());
  });

  handleSelect(id: string) {
     // ...
  }
}

Notice what is missing:

  • No dependency arrays: Signals automatically track their dependencies. You cannot “forget” to add a variable to the array.
  • No useMemo or useCallback: computed values are memoized by default. Methods in the class are stable references by definition.
  • No Stale Closures: Because Signals always hold the current value, the class of bugs related to “stale state” inside callbacks or effects effectively vanishes.

The Effect of Simplicity

The difference is most stark when handling side effects. In React, useEffect is a powerful but perilous tool. It combines lifecycle events (mount/unmount) with state synchronization, leading to complex bugs when dependencies are mishandled.

In modern Angular, side effects are separated. The constructor handles initialization (mounting equivalent), and the effect() primitive is reserved strictly for reactive side effects (like logging or syncing to local storage).

By decoupling “rendering” from “running logic,” Angular allows the developer to stop thinking about the framework’s render cycle and focus entirely on how data flows through the application.

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Syntax, Semantics, and Scale

While architecture defines the macrostructure, the daily experience of a developer is defined by the friction, or flow, of writing code. The fundamental divergence here is between React’s “All-in-JavaScript” philosophy and Angular’s “Structure-First” approach.

Both models are capable, but they place the cognitive load in different places.

1. Visual Clarity: JSX vs. Built-in Control Flow

React’s JSX is powerful because it brings the full expressiveness of JavaScript into the view. However, because JSX lacks its own control flow syntax, developers must rely on JavaScript operators (? :, &&, .map) to handle logic.

In simple scenarios, this may look elegant. But in complex, nested scenarios (common in real-world apps), the code can become difficult to scan. You are reading logic syntax, not layout structure. Angular’s new control flow syntax (@if, @for) abstracts this logic, making the template read like a document outline.

React (All-in-JS: Ternaries and .map) The developer must parse the nested ternary structure and the .map() method call to understand the flow, mentally separating the JavaScript logic from the resulting HTML elements.

return (
  <div className="dashboard">
    {isLoading ? (
      <Spinner />
    ) : user ? (
      // Nested Conditional: If user is present...
      user.role === 'admin' ? (
        // Iteration via .map() forces logic into the view
        user.logs.map((log) => (
          <AdminLog key={log.id} log={log} />
        ))
      ) : (
        <UserDashboard data={data} />
      )
    ) : (
      <LoginForm />
    )}
  </div>
);

Angular (Semantic Blocks: @if and @for) The structural nature of the blocks clearly dictates the hierarchy. Iteration is handled via a dedicated, readable, HTML-like directive (@for).

<div class="dashboard">
  @if (isLoading()) {
    <app-spinner />
  } @else if (user(); as u) {
    @if (u.role === 'admin') {
      @for (log of u.logs; track log.id) {
        <app-admin-log [log]="log" />
      }
    } @else {
      <app-user-dashboard [data]="data()" />
    }
  } @else {
    <app-login-form />
  }
</div>

2. State Management: Mental Overhead vs. API Consistency

React’s useState is flexible, but that flexibility delegates convention to the developer. Because hooks return a tuple ([value, setter]), the team must agree on naming conventions, and the component scope becomes crowded with identifiers.

For example, if a component has 10 pieces of state, the developer manages 20 distinct variables (10 values + 10 functions). Angular Signals reduce this noise by encapsulating the value and the mutation API in a single object.

React (Tuple Explosion)

// Developer must decide on naming conventions for every atom of state
const [data, setData] = useState([]);
const [isMenuOpen, setIsMenuOpen] = useState(false); // or setMenuOpen?
const [filter, setFilter] = useState('all'); 

// Updating requires calling the specific function for that specific variable
const reset = () => {
  setIsMenuOpen(false);
  setFilter('none');
}

Angular (Unified Signal API)

// 3 Signals = 3 Identifiers. The API is consistent.
data = signal([]);
isMenuOpen = signal(false);
filter = signal('all');

reset() {
  // No need to look up the setter name; .set() is universal
  this.isMenuOpen.set(false);
  this.filter.set('none');
}

3. Separation of Concerns: Logic vs. Template

React’s “All-in-JS” model means the Component Function contains everything: hooks, derived state calculations, event handlers, and the returned JSX. In large components, this can lead to a “Vertical Wall of Code” where it is difficult to distinguish where the logic ends and the view begins.

Angular enforces a structural separation. The Class defines the behavior (methods, state), and the Template defines the view. This physical separation helps developers categorize code mentally: “I am fixing a bug in the logic” vs “I am changing the layout.”

React (The Mixed Scope)

function UserProfile({ id }) {
  // 1. Hooks & Side Effects
  const [user, setUser] = useState(null);
  useEffect(() => { /* fetch logic */ }, [id]);

  // 2. Helper Logic mixed with View Logic
  const formatName = (u) => u.firstName.toUpperCase();

  // 3. The View (JSX)
  return (
    <div className="card">
      <h1>{user ? formatName(user) : 'Loading...'}</h1>
    </div>
  );
}

Angular (Distinct Layers)

// logic
@Component({ templateUrl: './user-profile.html' })
export class UserProfile {
  user = signal<User | null>(null);

  constructor() {
    // Effects are isolated in the constructor/injection context
    effect(() => this.fetchUser());
  }

  // Pure logic method
  formatName(u: User) {
    return u.firstName.toUpperCase();
  }
}
<!, view -->
<div class="card">
  @if (user(); as u) {
    <h1>{{ formatName(u) }}</h1>
  } @else {
    <h1>Loading...</h1>
  }
</div>

4. Predictability and Debugging: Trusting the Scheduler vs. Tracing the Graph

The most significant difference between these two rendering models is not speed, but predictability. The React model, while powerful, shifts the responsibility for determining when and where updates occur to an invisible, asynchronous runtime scheduler. The Angular Signals model, conversely, keeps the update process synchronous and traceable directly within the application’s data flow.

In React, the concepts listed above, such as batching, reconciliation, and scheduler prioritization, are powerful optimizations, but they introduce a fundamental uncertainty: the process is indirect and opaque.

This opacity leads to challenging debugging scenarios:

  • When a bug occurs, it is often difficult to determine why a component re-rendered, requiring complex profiling tools to inspect the entire virtual DOM process.
  • Because Effects run after the commit phase, developers often struggle with stale data in closures, forcing constant manual dependency management.
  • The asynchronous, scheduled nature means developers cannot reliably predict the exact frame or order in which two separate state updates will be committed to the DOM.

This validates the rule: You mutate state and hope React renders what you expect, when it decides to.

Angular Signals return control to the developer. The process is linear, synchronous, and deterministic.

When count.set(value) is called, the value is updated now. Only the template bindings directly dependent on count() are marked for update now. So, the system bypasses the complex reconciliation step entirely.

The mental model is simplified to: “This value changed, and only dependents update. Now.” This means that debugging an update in Angular typically involves tracing the dependency graph (what is consuming the signal) rather than profiling the entire virtual component tree. This direct link between data modification and UI update drastically lowers the cognitive overhead and makes large-scale applications far easier to reason about and stabilize over time.

5. Architecture and Convention: Dependency Injection vs. The Context Trap

The last major point of friction in large-scale applications is state propagation and service management. React’s freedom-first approach provides flexibility, but at a cost: it forces teams to constantly make fundamental architectural decisions that Angular handles out of the box.

In React, when a component needs external data (like user authentication status, configuration settings, or a database abstraction layer), it typically relies on one of three mechanisms:

  1. Passing data or functions down through many layers of components that don’t need the data themselves (prop drilling). This creates brittle interfaces and high maintenance overhead.
  2. Context API is React’s built-in mechanism for global state. However, Context is primarily a broadcast mechanism; it’s not a service locator. If a component consumes a Context value, and that value changes, all consuming components (even memoized ones) often re-render, leading to performance issues if the Context is used for frequently changing data.
  3. Libraries like Redux, Zustand, or Recoil are introduced to solve the Context/Prop Drilling problems. While powerful, this requires the entire team to adopt a new library, learn a new API, and integrate it into the existing framework, adding significant architectural setup time and maintenance overhead.

This freedom creates “decision fatigue” and leads to the Context Trap: an application where data retrieval, business logic, and view presentation are often tightly coupled within component functions.

Angular addresses this challenge using a robust, decades-proven software pattern: Dependency Injection (DI).

The Angular DI system is a sophisticated service locator that manages the lifecycle, scope, and instantiation of every service and component in the application. This system offers immediate benefits for productivity and consistency.

Business logic (data fetching, caching, transformation) lives exclusively in Services. Components only handle view logic. The framework enforces this separation by making service injection the primary mechanism for accessing data.

Testing a component becomes simple because its dependencies (services) are transparently declared and easily replaced with mocks during unit testing. There is no need to write complex wrapper components or manage virtual contexts.

Teams do not waste time debating state management architecture. They use the built-in DI system for services and Signals for component-local reactivity. This singular, powerful pattern leads to greater consistency across large teams and codebases.

Furthermore, the modern Angular API, leveraging Standalone Components and the inject function, has eliminated the boilerplate historically associated with DI, making it simpler than ever to access services.

Open Ecosystem vs the All-In-One Framework

One of the most underestimated differences between React and Angular is not technical, but structural. The React ecosystem thrives on diversity, experimentation, and modularity. This is a powerful advantage, yet it also leads to an undeniable form of fragmentation that teams must actively manage.

React gives developers extraordinary freedom, but that freedom comes with responsibility. Before a project can move forward, teams must align on a wide range of foundational choices:

  • State libraries: Redux, Zustand, MobX, Jotai, Recoil, Valtio, Signals, custom hooks, etc.
  • Routing landscape: React Router, TanStack Router, Next.js router, Expo Router, etc.
  • Form libraries: React Hook Form, Formik, Zod-based solutions, bespoke abstractions, etc.
  • Data fetching strategies: SWR, React Query, RTK Query, custom async patterns, etc.
  • SSR environments: Next.js, Remix, Astro, Gatsby, custom stacks, etc.

React does not provide a unified platform. It provides a rendering engine, inside a constantly shifting toolkit ecosystem.

As the ecosystem grows, the surface of decisions grows with it.

This creates:

  • choice paralysis
  • inconsistent architectures across teams
  • dependency on community conventions
  • long alignment cycles
  • onboarding slowdowns
  • knowledge silos within organizations

Angular approaches this from a completely different angle because it provides everything out of the box: routing, forms, dependency injection, an SSR pipeline, CLI tooling, testing utilities, a built-in reactivity model, state primitives, a coherent build system, well-defined conventions, and a clear architectural structure. All of these pieces are designed to work together from day one.

The result is an ecosystem that dramatically reduces foundational decisions, promotes a unified architectural vision, keeps development patterns consistent, creates a shared vocabulary across engineers, establishes a more predictable learning curve, and significantly accelerates onboarding.

Real-World Productivity

The real test of any frontend framework is not its syntax or benchmarks, but its ability to enable teams to build and maintain complex software efficiently. This is where modern Angular demonstrates a distinct advantage.

Angular’s cohesive architecture significantly reduces the time required to move from idea to implementation. Developers do not spend days evaluating libraries, integrating routers, aligning state management conventions, or debating folder structures. The framework provides a clear foundation that accelerates execution and minimizes the friction typically encountered during project setup.

Once development begins, the benefits compound:

  • Signals simplify state flows.
  • The dependency injection system keeps code modular and testable.
  • The CLI provides high-quality scaffolding and automation.
  • The built-in form engine eliminates entire categories of custom logic.

Most importantly, features evolve predictably. The mental cost of refactoring remains small, even under pressure.

Teams report faster onboarding curves, more consistent implementation quality, and fewer architectural regressions over time. In organizations where multiple teams collaborate on the same codebase, sometimes across years, this consistency becomes a strategic advantage rather than a technical preference.

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Final Thoughts: Modern Angular Is Easier

When viewed through the lens of its current architecture, modern Angular is no longer the heavyweight framework it used to be. The shift is structural, not cosmetic. By streamlining its reactive model, eliminating boilerplate modules, consolidating platform-level tooling, and clarifying the developer experience, Angular has removed much of the cognitive friction once associated with it.

In day-to-day development, this translates into fewer architectural negotiations, fewer decisions imposed on teams, fewer implicit behaviors to reverse-engineer, and far fewer surprises hidden behind abstractions. Developers spend more time solving product problems and less time navigating framework complexity.

React continues to stand out as a flexible and expressive library, yet the overall effort required to shape a stable, well-structured architecture around it has increased as the ecosystem has grown in size and complexity.

Modern Angular has evolved in a different direction, reaching a level of clarity, cohesion, and internal discipline that simplifies the initial learning process and provides a more sustainable foundation for long-term projects.

The post Modern Angular Is Easier Than React appeared first on International JavaScript Conference.

]]>
Angular 21: Signal Forms, Smart Styling, MCP & Beyond https://javascript-conference.com/blog/angular-21-signal-forms-smart-styling-mcp/ Fri, 14 Nov 2025 10:17:13 +0000 https://javascript-conference.com/?p=108526 Angular v21 marks a historic architectural shift, completing the framework’s transition to a Signals-first, zoneless core that redefines reactivity, performance, and developer experience. This article explores the new Signal Forms API and introduces Smart Styling, which is Angular’s native approach to class and style bindings for maximum clarity and efficiency. You will also discover how the emerging Model Context Protocol (MCP) Server integrates AI directly into the Angular CLI, paving the way for intelligent, context-aware code generation and automated migrations in future releases.

The post Angular 21: Signal Forms, Smart Styling, MCP & Beyond appeared first on International JavaScript Conference.

]]>
The Signals-First Revolution and the New Architectural Core

Angular v21 does not merely introduce a new set of features; it completes a fundamental architectural revolution, decisively marking the framework’s entrance into a Signals-first, zoneless paradigm. This release resolves the historical performance and complexity bottlenecks that often accompany large-scale Angular applications, positioning the framework for superior runtime performance and a dramatically improved Developer Experience (DX).

The most profound shift lies in the near-complete transition from the coarse-grained, Zone.js-based change detection to a model of fine-grained reactivity driven by Signals. The traditional approach, which relied on Zone.js to patch browser asynchronous APIs, often resulted in unnecessary re-checks across the entire component tree after any async event.

Angular v21 finalizes the stability of its zoneless APIs, allowing applications to operate without this performance-limiting layer. This architectural change immediately results in smaller application bundles, faster startup times, and minimal runtime overhead, as the framework updates only the specific view nodes that depend on the changed signal value. This move aligns Angular with the highest performance standards set by other modern frameworks.

Complementing this performance core is the continued maturation of the standalone architecture. By making standalone components and APIs the default for new applications, Angular v21 dramatically reduces reliance on NgModules, cutting down on boilerplate and streamlining project structure. This modular approach is vital for enterprise teams utilizing Micro-Frontend Architectures, enabling components to be easily portable and consumed across different applications. The release simplifies essential, common development tasks. For instance, HttpClient is now included by default for standalone projects, removing a small but persistent friction point for developers.

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

These architectural advancements, Signals, zoneless readiness, and standalone stability, are the essential groundwork that enables the two major feature highlights of the release: the highly anticipated Signal Forms and the substantial ergonomic improvements to Smart Styling. These features move beyond simple updates, offering new patterns that will redefine component authorship and state management in Angular applications moving forward.

Looking ahead, the framework is strategically positioned in two key areas. First, the experimental work on the Angular CLI MCP Server is set to mature, paving the way for advanced AI-powered workflows in subsequent releases. This will allow sophisticated models to interact directly with the CLI’s internal tools to perform context-aware code generation, migration, and style adherence. Secondly, the successful transition of Forms to Signals now dictates the future of other packages. Subsequent major versions will focus on rolling out signal-based APIs for the Router and HttpClient (including the stabilization of the resource function), leading to a unified, end-to-end reactive data flow that completely simplifies asynchronous state management across the entire application. The future is an Angular where reactivity is not an optional add-on, but the fundamental, high-performance core of every component and service.

A Deep Dive into Signal Forms

The introduction of Signal Forms is perhaps the most highly anticipated and impactful feature of Angular v21, directly addressing the longstanding issues of complexity, verbosity, and leaky reactivity that plagued the previous Observable-based Reactive Forms API. This new system revolutionizes form handling by adopting a model-first, declarative approach that is inherently compatible with the new zoneless architecture.

Problem Solved: Eliminating Imperative Complexity

The traditional Reactive Forms API required the imperative creation and synchronization of state. Developers had to manually instantiate FormGroup and FormControl instances, define validators, and then manage state changes (like conditional disabling or cross-field validation) by manually subscribing to the valueChanges Observable. This led to error-prone subscription management, potential memory leaks from forgotten unsubscribe calls, and complex logic dispersed across the component class.

Signal Forms resolves this by making the form state a collection of native Signals. This shifts the forms architecture from managing a stream of values to managing a set of reactive values.

The result is:

  1. The form structure and its initial value are defined by a simple, strongly-typed model object wrapped in a writable signal.
  2. Form control values, validity, and status (e.g., touched, dirty) are all exposed as signals. The application’s view automatically reacts to these signals, completely eliminating the need for manual subscriptions and the ChangeDetectorRef when handling form state.
  3. Conditional logic, such as disabling one field based on another’s value, is defined directly within the form builder function using declarative statements that operate on the signals, not via manual valueChanges subscriptions.

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Code Example: Declarative User Registration Form

To illustrate this simplification, consider a basic user registration form. The code demonstrates how the model, form definition, and template binding become seamlessly interconnected.

import { Component, signal } from '@angular/core';
import { form, required, email, minLength, Control } from '@angular/forms/signals';

// Define the clear, strongly-typed data model
interface UserRegistration {
  email: string;
  username: string;
}

@Component({
  selector: 'app-user-form',
  // Import the Control directive for template binding
  imports: [Control],
  template: `
    <form (submit)="onSubmit($event)">
      <label>Email:
        <input type="email" [control]="registrationForm.email" />
      </label>
      @if (registrationForm.email().invalid()) {
        <div class="error-message">Email is invalid or required.</div>
      }
      
      <label>Username:
        <input type="text" [control]="registrationForm.username" />
      </label>
      @if (registrationForm.username().invalid()) {
        <div class="error-message">Username must be at least 4 characters.</div>
      }

      <button type="submit" [disabled]="registrationForm().invalid()">
        Register
      </button>
      
      <pre>Form Value: {{ registrationForm.value() | json }}</pre>
    </form>
  `
})
export class UserFormComponent {
  // 1. Define the model signal
  private readonly initialModel = signal<UserRegistration>({
    email: '',
    username: ''
  });

  // 2. Define the Signal Form, including validation rules
  public readonly registrationForm = form(
    this.initialModel,
    (p) => [ // 'p' is the path object representing the form structure
      required(p.email, { message: 'Email is required' }),
      email(p.email, { message: 'Must be a valid email format' }),
      required(p.username, { message: 'Username is required' }),
      minLength(p.username, 4, { message: 'Min length is 4' }),
    ]
  );

  onSubmit(event: SubmitEvent): void {
    event.preventDefault();
    if (this.registrationForm().valid()) {
      console.log('Submitted data:', this.registrationForm.value());
      // Here you would typically call a service to submit the data
    } else {
      console.error('Form is invalid.');
      // Logic to mark all fields as touched to show all errors
      // (The new API has methods like markAllAsTouched for this)
    }
  }
}

Here, the form’s structure is implicitly defined by the UserRegistration interface and the initialModel signal. The form() function then wraps this model.

Validators (requiredemailminLength) are not passed as arrays as constructor arguments but are declared separately in a functional array within the form() call. This separates model definition from validation logic, improving readability.

In the template, the new [control] directive replaces formControlName and ngModel. It binds the input element directly to a control signal (registrationForm.email), making the binding explicit and reactive.

Checking validity is trivial: @if (registrationForm.email().invalid()) immediately accesses the state of the email control signal without any pipe or subscription. Similarly, the submit button is disabled via [disabled]=”registrationForm().invalid()”, leveraging the parent form’s computed validity signal.

So, by embracing Signals, the Signal Forms API simplifies the core development loop: define the model, apply validations functionally, and access state reactively in the template. This makes forms significantly easier to reason about, test, and maintain, especially in large, complex applications.

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Explanation of Angular Form State Methods

Angular forms rely on tracking various states to determine when to show errors or enable submission.

The methods valid() and invalid() reflect the current validation status of a control or the entire form based on the rules you defined. For instance, the form submission button uses [disabled]=”registrationForm().invalid()” to ensure it can only be clicked if the data meets all requirements, meaning registrationForm().valid() is true.

// If the email field passes all validation rules 
// Example: The user typed "[email protected]" registrationForm.email().valid(); // true 

// If the username is empty (and required) 
// Example: The user typed nothing registrationForm.username().invalid(); // true

The methods dirty() and pristine() track whether the user has modified the initial value of a control. A control starts as pristine (control().pristine() is true). As soon as the user types a single character, it becomes dirty, making control().dirty() true, and it remains so until you manually reset the form.

// Initial load state 
// Example: The form is first displayed registrationForm.email().pristine(); // true 

// After the user types "a" in the email field 
registrationForm.email().dirty(); // true

The methods touched() and untouched() track user interaction by focus. A control starts as untouched, and only becomes touched (control().touched() is true) after the user has focused on the field and then clicked or tabbed away (the “blur” event). This is often combined with the invalid state to display errors, as seen in the template where we check @if (registrationForm.email().invalid() && registrationForm.email().touched()) to avoid showing an error the moment the form first loads.

<!-- The error message only shows if the email is invalid AND the user has left the field --> 
@if (registrationForm.email().invalid() && registrationForm.email().touched()) 
{ <!-- Show error here --> }

Finally, the markAllAsTouched() method, which is not a state but an action, forces the touched() status to true for every control inside the form group. We use this.registrationForm().markAllAsTouched() inside the onSubmit method when the form is invalid to ensure all users see all potential errors right away, instead of only seeing them one by one as they manually interact with each field.

// Called when the user clicks submit but the form is invalid 
this.registrationForm().markAllAsTouched();

You can see a fully operational example in the code below.

user registration angular 21

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { FormGroup, FormControl, Validators, ReactiveFormsModule, ValidationErrors } from '@angular/forms';
import { JsonPipe, NgIf, NgClass } from '@angular/common';

// The application uses standard Reactive Forms (FormGroup, FormControl)
// instead of the experimental Signal Forms to ensure stable compilation.

@Component({
  standalone: true,
  selector: 'app-root', 
  // Import ReactiveFormsModule to enable directives like formGroup and formControlName
  imports: [ReactiveFormsModule, NgIf, JsonPipe, NgClass], 
  template: `
    <div class="p-8 max-w-lg mx-auto bg-white shadow-xl rounded-xl">
      <h2 class="text-3xl font-extrabold text-indigo-700 mb-6 border-b pb-2">User Registration</h2>
      
      <!-- Bind the formGroup to the HTML form element -->
      <form [formGroup]="registrationForm" (ngSubmit)="onSubmit()" class="space-y-5">

        <!-- Email Field -->
        <div class="form-group">
          <label class="block text-sm font-medium text-gray-700 mb-1" for="email">Email:</label>
          <input id="email" type="email" formControlName="email" 
                 placeholder="[email protected]"
                 class="w-full p-2 border border-gray-300 rounded-lg focus:border-indigo-500 transition duration-150" />
          
          <!-- Access the email control to check state and errors -->
          @if (emailControl.invalid && emailControl.touched) {
            <div class="mt-1 text-sm text-red-600">
              {{ getFirstErrorMessage(emailControl.errors) }}
            </div>
          }
        </div>
          
        <!-- Username Field -->
        <div class="form-group">
          <label class="block text-sm font-medium text-gray-700 mb-1" for="username">Username:</label>
          <input id="username" type="text" formControlName="username" 
                 placeholder="min. 4 characters"
                 class="w-full p-2 border border-gray-300 rounded-lg focus:border-indigo-500 transition duration-150" />

          <!-- Access the username control to check state and errors -->
          @if (usernameControl.invalid && usernameControl.touched) {
            <div class="mt-1 text-sm text-red-600">
              {{ getFirstErrorMessage(usernameControl.errors) }}
            </div>
          }
        </div>

        <!-- Submit Button -->
        <button type="submit" 
                [disabled]="registrationForm.invalid"
                class="w-full py-2 px-4 rounded-lg text-white font-semibold transition duration-200"
                [ngClass]="registrationForm.invalid ? 'bg-gray-400 cursor-not-allowed' : 'bg-indigo-600 hover:bg-indigo-700 shadow-md'">
          Register
        </button>
          
        <!-- Form State Debug -->
        <div class="p-3 bg-gray-50 border rounded-lg text-xs font-mono mt-5">
          <p>Valid: {{ registrationForm.valid | json }}</p>
          <p>Touched: {{ registrationForm.touched | json }}</p>
          <p>Dirty: {{ registrationForm.dirty | json }}</p>
          <pre>Value: {{ registrationForm.value | json }}</pre>
        </div>
      </form>
    </div>
  `,
  styles: [`
    /* Using native CSS for the component for clean styling */
    .form-group {
      margin-bottom: 1.25rem;
    }
  `],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
  // Define the main form group with all controls and validators
  public registrationForm = new FormGroup({
    email: new FormControl('', {
      validators: [
        Validators.required, 
        Validators.email
      ],
      nonNullable: true
    }),
    username: new FormControl('', {
      validators: [
        Validators.required, 
        Validators.minLength(4)
      ],
      nonNullable: true
    })
  });

  // Helper getters for easy access in the template
  get emailControl(): FormControl {
    return this.registrationForm.get('email') as FormControl;
  }
  
  get usernameControl(): FormControl {
    return this.registrationForm.get('username') as FormControl;
  }


  onSubmit(): void {
    
    // Check form validity (using the .valid property)
    if (this.registrationForm.valid) {
      console.log('Submitted data:', this.registrationForm.value);
      // Successful submission logic (e.g., API call)
    } else {
      console.error('Form is invalid. Marking all controls as touched.');
      
      // Action: Mark all controls as touched to trigger immediate error display 
      this.registrationForm.markAllAsTouched();
    }
  }

  // Helper to extract the first error message from the ValidationErrors object
  getFirstErrorMessage(errors: ValidationErrors | null): string {
    if (!errors) {
      return '';
    }
    const errorKey = Object.keys(errors)[0];
    
    // Custom messages are not easily passed with standard Validators, 
    // so we return a friendly default based on the validator key.
    if (errorKey === 'required') {
        return 'This field is required.';
    }
    if (errorKey === 'email') {
        return 'Must be a valid email format.';
    }
    if (errorKey === 'minlength' && errors['minlength']) {
        const requiredLength = errors['minlength'].requiredLength;
        return `Minimum length is ${requiredLength} characters.`;
    }

    // Fallback
    return `Validation failed for: ${errorKey}`;
  }
}

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Smart Styling, Performance, and Future Outlook

Angular v21 completes the picture of a modernized framework not only through its core architectural shifts but also by refining its template directives, turbocharging its Server-Side Rendering (SSR) capabilities, and laying the strategic groundwork for future innovations like sophisticated AI tooling.

The concept of Smart Styling in Angular v21 revolves around an official endorsement of native HTML bindings over historical directive abstractions, improving both performance and code clarity. The framework is guiding developers away from the use of NgClass and NgStyle, which are being softly deprecated. While these directives remain functional for backward compatibility, the recommended best practice is now to use the native [class] and [style] bindings. This strategic pivot aligns Angular more closely with standard web practices and leverages the native efficiency of the browser’s DOM manipulation.

The rationale is twofold: performance gains and simplification. Native bindings directly manipulate DOM properties, removing the small but measurable overhead of intermediary directives, leading to cleaner code and simpler debugging. For instance, dynamic class assignment is now cleaner using expressions like [class.active]=”isActiveSignal()” seamlessly integrated with the Signal-driven reactivity. This emphasis on native expression binding is part of a broader template optimization effort, which also includes the final stabilization of the new built-in template control flow (@if, @for), eliminating the need for *ngIf and the associated CommonModule boilerplate.

Example: Smart Styling with Native Bindings

The following component demonstrates how to use native property bindings ([class] and [style]) with Signals, which is the recommended practice over the soon-to-be-removed NgClass and NgStyle directives. This approach is cleaner, more performant, and instantly familiar to anyone experienced with standard HTML and JavaScript.

The component defines signals for state (an alert count and a theme preference) and uses a computed() signal to derive complex styling properties.

import { Component, signal, computed } from '@angular/core';

@Component({
  standalone: true,
  selector: 'app-smart-alert',
  template: `
    <div 
      [class.error]="alertCount() > 0"
      [class.warn]="alertCount() > 5"
      [class.dark-theme]="isDarkTheme()"
      [style.font-size.px]="alertFontSize()"
      [style.border-color]="borderColorComputed()"
    >
      You have {{ alertCount() }} outstanding alerts.
      <button (click)="alertCount.update(c => c + 1)">Add Alert</button>
      <button (click)="isDarkTheme.set(!isDarkTheme())">Toggle Theme</button>
    </div>
  `,
  styles: [`
    .error { color: red; }
    .warn { font-weight: bold; }
    .dark-theme { background-color: #333; color: #fff; }
  `]
})
export class SmartAlertComponent {
  // Writable Signals for state
  public readonly alertCount = signal(2);
  public readonly isDarkTheme = signal(false);

  // Computed Signal for derived class property
  public readonly alertFontSize = computed(() => {
    // Dynamically increase font size based on the alert count
    return 16 + Math.min(this.alertCount(), 10);
  });

  // Computed Signal for derived style property
  public readonly borderColorComputed = computed(() => {
    // Logic to switch border color based on theme
    return this.isDarkTheme() ? '#777' : '#000';
  });
}

Now we have:

Direct Class Binding ([class.name]=”expression”):

  • Old Way: <div [ngClass]=”{‘error’: alertCount > 0}”>… required an external object evaluation or a dictionary-style input.
  • New Way: <div [class.error]=”alertCount() > 0″>… uses a direct property binding. Angular adds the class error only when the bound Signal expression is truthy. This is the simplest, most performant way to toggle a single class.

Native Style Binding ([style.property.unit]=”value”):

  • Old Way: <div [ngStyle]=”{‘font-size.px’: 16 + alertCount}”>… relied on the NgStyle directive.
  • New Way: <div [style.font-size.px]=”alertFontSize()”>… uses the native [style] binding. The .px suffix is an Angular feature that binds the unit directly to a number type, ensuring correct and efficient DOM manipulation without string concatenation boilerplate in the template.

Signal Integration:

Both the class and style bindings are consuming the results of signals (alertCount(), isDarkTheme(), and the derived alertFontSize()). Because these are native Signals, Angular’s fine-grained reactivity ensures that if the alertCount updates, only the specific class and style properties related to the alert count will be re-evaluated and applied to the DOM, preserving maximum performance and avoiding unnecessary checks.

This declarative pattern, combining Signals with native bindings, is the essence of smart styling in Angular v21, leading to code that is more readable, maintainable, and highly optimized for the modern web.

AI Integration: The CLI Model Context Protocol (MCP) Server

Angular v21 introduces a profound, strategic enhancement that prepares the framework for the next era of software development: the Angular CLI Model Context Protocol (MCP) Server. This addition is not a developer-facing feature in the traditional sense, but rather a critical architectural middleware that transforms generic Large Language Models (LLMs) and coding assistants into highly specialized, context-aware Angular co-pilots. This mechanism is paramount to maximizing developer velocity while simultaneously preserving code quality and project consistency.

Bridging the Gap: Context vs. Training Data

The core problem the MCP Server solves is the inherent limitation of AI training data. An LLM’s knowledge of Angular is static, based on information available up to its last training cut-off. For a rapidly evolving framework like Angular, this means models often generate outdated code (e.g., using deprecated NgModules, RxJS patterns, or old syntax).

The MCP Server effectively closes this gap by providing real-time, authoritative context. It acts as a specialized agent that runs alongside the Angular CLI, exposing a curated set of internal tools to the AI model. When a developer asks the AI to perform a task, the AI can call these tools to retrieve the live, current state of the project and the framework.

Strategic Use Cases for Development Teams

The integration of the MCP Server enables several powerful, project-specific workflows:

  1. When a developer prompts the AI to “generate a component for the checkout feature,” the AI can call a tool exposed by the MCP Server to read the local angular.json file. This provides instant knowledge of the project’s naming conventions, component prefixes, styling preferences (e.g., SCSS vs. CSS), and directory structure. The resulting code is guaranteed to conform to the team’s established standards, minimizing time spent on boilerplate and code review corrections.
  2. The Server can expose tools related to migration schematics. This allows the AI to analyze existing code and automatically apply the necessary Signal-based refactoring or structural changes (like converting *ngIf to @if). This dramatically reduces the risk and manual effort involved in keeping a large codebase current with the latest high-performance best practices.
  3. The Server ensures the AI always references the correct, current API. For instance, when generating code using Signal Forms, the AI is prompted to retrieve the v21 API details via the Server’s tools, preventing it from incorrectly generating logic based on the legacy Observable patterns.

Moreover, the MCP Server is designed with necessary security and integrity safeguards. The protocol dictates that the CLI only exposes specific, predefined tools, avoiding general-purpose file system or shell execution access. This strict limitation on the model’s access minimizes the attack surface. Furthermore, the final stage of any AI-driven workflow requires the “Human-in-the-Loop” (HITL). All generated code is provided as a suggestion or diff, ensuring that the developer remains the ultimate authority, reviewing and testing every AI-generated change before it is committed to the codebase. The MCP Server thus empowers teams to leverage AI as a sophisticated accelerator without compromising on quality or control.

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Conclusion

Angular 21 represents a pivotal step in the framework’s evolution, solidifying its commitment to performance, simplicity, and developer experience. By embracing Signals for state management and modernizing its core architecture, Angular offers developers a powerful, yet increasingly streamlined, platform for building robust applications. These updates reduce boilerplate, improve runtime performance, and ensure that Angular remains a top-tier choice for scalable, enterprise-level development.

By now, developers are better equipped than ever to focus on solving complex business problems with clean, efficient, and maintainable code.

The post Angular 21: Signal Forms, Smart Styling, MCP & Beyond appeared first on International JavaScript Conference.

]]>
What’s New in Angular 21? https://javascript-conference.com/blog/angular-21-signal-forms-zoneless-vitest/ Wed, 05 Nov 2025 12:32:53 +0000 https://javascript-conference.com/?p=108516 Angular 21 introduces a new era of efficiency and developer-friendly design. With experimental Signal Forms and default zoneless change detection, this release focuses on performance and reactivity. Let's explore how these updates shape the framework’s future and simplify everyday development.

The post What’s New in Angular 21? appeared first on International JavaScript Conference.

]]>
If you’ve been following Angular’s journey, version 21 brings some fresh air with features that many developers have been waiting for. The long-awaited Signal Forms are finally arriving. Although they’re experimental, this feature gives a glimpse into a smoother, more reactive approach to handling forms in Angular. Meanwhile, zoneless change detection is now enabled by default, boosting the framework’s performance and making your life easier. Let’s go over some of the cool updates coming in Angular 21.

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Signal Forms

Angular 21 introduces Signal Forms, an experimental but promising feature that offers a fresh, declarative, and reactive way to manage form state using signals. To better understand how Signal Forms work in practice, let’s walk through the basic steps of creating one, starting with defining your form’s state as a signal.

crewMember = signal<CrewMember>(
    {
      name: '',
      imageUrl: '',
      position: ''
    }
  );

  crewForm = form(this.crewMember);

This setup defines a signal holding the crew member’s model. You can then pass this model to Angular’s form() function to create the reactive form tree reflecting this structure.

The next step is to bind individual signal form fields to your HTML elements using the Field directive. This directive creates a two-way binding between the input element and the form’s signal model. Any changes in the input automatically update the form state, and any updates to the model immediately reflect in the input. Using it is really straightforward: just add [field] to your input elements and assign the corresponding form field. Remember to import the Field directive in your component’s imports array; otherwise, Angular won’t recognize it.

<input type="text" [field]="crewForm.name" placeholder="Enter pirate name">
<input type="url" [field]="crewForm.imageUrl" placeholder="Enter image URL">
<input type="text" [field]="crewForm.position" placeholder="Enter crew position">
…
<!--Preview-->
<div>
   <p>Name: {{ crewForm.name().value() }}</p>
   <p>Position: {{ crewForm.position().value() }}</p>
</div>
<img [src]="crewMember().imageUrl">

In this example, you can see inputs bound to the crewForm fields for name, image URL, and position. Just below, there’s a live preview that shows how you can display the current form values by accessing crewForm.name().value() or crewForm.position().value(). Similarly, the image URL is read from the original crewMember signal, demonstrating how both the crewForm and the crewMember signal stay in sync.

Signal Form live preview

Figure 1: Signal Form live preview

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Validation

To add validation in Signal Forms, pass a schema function into the form() method. The function can include built-in validators, such as required, email, or minLength, alongside your own custom validation logic. Error messages can be customized via options, allowing friendly and precise feedback for users interacting with forms.

crewForm = form(this.crewMember, (path) => {
    required(path.name, { message: 'Name is required' });
    minLength(path.name, 2, { message: 'Name must be at least 2 characters long' });
    required(path.position, { message: 'Position is required' });
    required(path.imageUrl, { message: 'Image URL is required' });
  });

To show validation errors for a form field, first check if the field has been touched (if the user has interacted with it) and is currently invalid. This prevents displaying errors like ‘required’ prematurely before the user starts typing.

You can get the list of errors for the field by accessing its errors() signal, and then display the error message.

@if(crewForm.name().touched() && crewForm.name().invalid()) {
     <ul class="error-message">
       @for(error of crewForm.name().errors(); track $index) {
            <li>{{ error.message }}</li>
       }
     </ul>
 }

Signal Form validation errors

Figure 2: Signal Form validation errors

These examples illustrate the basic usage of Signal Forms to demonstrate core concepts. Check the Angular official docs to learn more about Signal Forms and their evolving functionality. Since this is an experimental API, expect some changes, but also a bright future for building forms declaratively and reactively.

Zoneless by default

Starting with Angular 21, zoneless change detection is now enabled by default. No more Zone.js dependency. The Zoneless API has been stable since Angular 20.2, but version 21 takes it further: there’s no need to import provideZonelessChangeDetection in your app config, as all new Angular applications are now zoneless out of the box.

In a zoneless app, change detection no longer triggers automatically on every async task, like HTTP requests, observables, or timers such as setTimeout or setInterval. This is a big shift compared to how Zone.js worked. Now, change detection runs only when explicitly triggered by certain actions, including:

  • Async pipe
  • User-bound events like clicks or input events
  • Signal value update used in the template
  • markForCheck()
  • call to ComponentRef.setInput()

Going zoneless breaks free from the old Zone.js magic, so change detection fires only on explicit triggers you control, avoiding unnecessary change detection cycles and resulting in better app performance. Removing Zone.js also shrinks the bundle size, which improves Core Web Vitals. Debugging gets cleaner as well, since stack traces are no longer polluted by Zone.js. For best performance, pairing zoneless mode with the OnPush strategy is highly recommended.

Another important advantage is improved compatibility with the wider ecosystem. Since Zone.js patches browser APIs, it sometimes struggles to keep up with new APIs or modern JavaScript features like async/await, which require special handling. Eliminating Zone.js removes this layer of complexity, leading to better long-term maintainability and fewer compatibility headaches.

For in-depth details, migration advice, and performance insights, check out my full guide.

Vitest – New Default Testing Framework

Angular 21 introduces Vitest as the new standard testing framework, replacing Jasmine and Karma for newly created projects. This shift comes after years of uncertainty following Karma’s deprecation in 2023, providing Angular developers with a clear, modern, and efficient testing solution.

Key Benefits:

  • Fast test runs powered by the Vite build tool
  • Native support for TypeScript and ESM
  • Real browser environment testing
  • Modern and rich API

Angular’s move to Vitest means better alignment with the modern JS ecosystem, and future migration utilities will ease switching from Jasmine. Developers will run tests the same way with ng test. Importantly, Jasmine and Karma can still be chosen instead of Vitest if needed.

The Vitest test result in console

Figure 3: The Vitest test result in console

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Angular ARIA

Angular ARIA is a library created in response to developer requests for accessible components that are simpler to style. It provides a collection of headless Angular directives implementing common accessibility patterns without any predefined styles, allowing developers full control over styling.

Currently, the Angular ARIA library includes accessible directives for the following UI components:

  • Accordion
  • Combobox
  • Listbox
  • Radio Group
  • Tabs
  • Toolbar
<div ngListbox>
      @for (item of crew.value(); track item.id) {
        <div [value]="item.name" ngOption>{{ item.name }}</div>
      }
    </div>

ARIA roles and attributes automatically added by using Angular ARIA directives

Figure 4: ARIA roles and attributes automatically added by using Angular ARIA directives

Other Improvements

Angular 21 goes beyond major new features by delivering various improvements, migrations, and quality enhancements that together modernize and optimize Angular apps.

  • The HttpClient is built in by default, so new projects no longer require manual setup of provideHttpClient().
  • Migration Scripts:
    • Migration from NgClass to class bindings:
      ng generate @angular/core:ngclass-to-class
    • Migration from NgStyle to style bindings:
      ng generate @angular/core:ngstyle-to-style
    • Migration of RouterTestingModule usages inside tests to RouterModule:
      ng generate @angular/core:router-testing-module-migration
    • Replacement of CommonModule imports with standalone imports:
      ng generate @angular/core:common-to-standalone
  • CLI support for Tailwind CSS config generation, making it easier to set up Tailwind CSS in Angular projects right from project creation.

CLI support for Tailwind CSS config generation

Figure 5: CLI support for Tailwind CSS config generation

In addition to these changes, Angular 21 includes numerous bug fixes, performance improvements, and developer experience enhancements that make the framework more stable, efficient, and user-friendly.

iJS Newsletter

Join the JavaScript community and keep up with the latest news!

[mc4wp-simple-turnstile]

Conclusion

Angular 21 delivers a thoughtful balance of innovation and refinement, introducing tools that make modern app development more efficient and enjoyable. Signal Forms, Vitest, default zoneless mode, and Angular ARIA directives all emphasize what this update is about: speed, clarity, and accessibility.

Angular continues to prove that a mature framework can still innovate, adapt, and surprise.

References

  1. Angular documentation
  2. ng-conf 2025 LIVE Angular Team Keynote
  3. Vitest documentation

The post What’s New in Angular 21? appeared first on International JavaScript Conference.

]]>