Rethinking reactivity in Angular

Andrei Antal

09.10.2025 Engineering Day @Raiffeisen Bank

Preamble

import { Component } from '@angular/core';

@Component({
  selector: 'random',
  templateUrl: './random.component.html',
})
export class RandomComponent {
  // bla bla bla code

  updateRandomData() {
    
    ...
    
    // DO NOT REMOVE!!!
    // for some reason angular won't detect changes, using setTimeout as a last resort, YOLO!
    setTimeout(() => {
      this.randomData = 'Updated random data';
    }, 0);
  }
}
// Complex search stream
const searchStream$ = input$.pipe(
  switchMap(query =>
    merge(refresh$, autoRefresh$).pipe(
      startWith(null), // initial fetch
      withLatestFrom(of(query)),
      tap(([_, q]) => console.log('Preparing fetch for:', q)),
      concatMap(([_, q]) =>
        from(fetchData(q)).pipe(
          retryWhen(errors =>
            errors.pipe(
              tap(err => console.warn('Retrying due to error:', err)),
              delay(1000)
            )
          ),
          catchError(err => of(`Failed to fetch: ${err}`)),
          map(result => ({ query: q, result })), // enrich data
          tap(data => console.log('Fetched data object:', data)),
          scan((acc, curr) => [...acc, curr.result], []), // accumulate results
          tap(acc => console.log('Accumulated results:', acc)),
          shareReplay(1), // replay last result for late subscribers
        )
      ),
      finalize(() => console.log(`Cleaning up stream for query: ${query}`))
    )
  ),
  exhaustMap(data => of(data).pipe(
    tap(d => console.log('Passing data to final consumer:', d))
  )),
  takeUntil(interval(30000)), // auto-unsubscribe after 30s
);
import { of, combineLatest, concat } from 'rxjs';
import { delay, startWith } from 'rxjs/operators';

// Simulate loading states: true -> false
const loading$ = of(true).pipe(
  concat(of(false).pipe(delay(1000)))
);

// Simulate a data fetch that takes 2s
const data$ = of(['Item A', 'Item B']).pipe(delay(2000), startWith([]));

// Combine latest
combineLatest([data$, loading$]).subscribe(([data, loading]) => {
  console.log({ loading, data });
});


//  { loading: true, data: [] }
//  { loading: false, data: [] } ← glitch!
//  { loading: false, data: ['Item A','Item B'] }

Andrei Antal

@andrei_antal

  • web technologies & ux consultant
  • trainer
  • music and history lover
  • community activist

organizer for ngBucharest

Hello!

History with Raiffesen

Rethinking Reactivity

How Angular’s shift toward fine-grained reactivity (with signals) changes the way we design, think, and code.

Reactivive change detection

State in web apps

const bookTitle = "Alchemy for beginners";

const chaptersTitles = [ "Equipment" ]

let book = {
    title: bookTitle,
    chapters: chapterTitles
  }

.....
      

Strings, numbers, arrays, objects etc.

Document Object Model

(DOM)

(rendering)

User Interface = function(State)

(Data projection)

Reactive interfaces

Change sources in web apps

  • User input (event callbacks)
  • Async requests (xhr requests)
  • Browser APIs - Promise, requestAnimationFrame 
  • Timers (setInterval, setTimeout)

Detecting changes in Angular

@Component({
  selector: 'todo-item',
  template: `
    @for(let todo of todos; track $index) {
      <div class="todo">
        {{todo.owner.firstname}} - {{todo.description}} <br />
        completed: {{todo.completed}}
      </div>
      <button (click)="completeTodo(todo.id)">COMPLETE</button>
    }`
})
class TodosComponent implements OnInit{
  #http = inject(Http)

  todos:Todo[] = [];

  ngOnInit() {
    this.#http.get('/todos')
      .subscribe(todos => this.todos = todos);
  
    setTimeout(() => {/*some other changes*/})
  }
  
  completeTodo(id: number) { ... }
}
function addEventListener(eventName, callback) {
     // call the real addEventListener
     callRealAddEventListener(eventName, function() {
        // first call the original callback
        callback(...);     
        // and then run Angular-specific functionality
        var changed = angular2.runChangeDetection();
        if (changed) {
            angular2.reRenderUIPart();
        }
     });
}

Zone.js

ApplicationRef

Detecting changes

"Dirty" marking

OnPush

import { ChangeDetectionStrategy } from '@angular/core';

@Component({
  ...
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class CounterComponent {
  @Input() data;
}
@Component({
  template: `
    <my-counter [data]="data" />
    <button (click)="count()">COUNT</button>
  `
})
export class App {
	data = { counter: 0 };
    
    count() {
      // won't trigger change detection
      this.data.counter++;

      // will trigger change detection
      this.data = { counter: this.data.counter + 1 };
    }
}

OnPush

Why mark ancestors?

RxJS

  • A stream of data ( 0 or more values of any type)

 

  • Pushed over any amount of time (can end, but not necessarily)

 

  • Cancelable ( can be stopped from emitting a value - unsubscribed)

 

  • Lazy - won't emit values until we subscribe to them

OBSERVABLES

Async pipe

Triggering CD

todos$ = this.#http.get('/todos') // uses XMLHttpRequest

$todos = new behaviourSubject(null)
addTodo(todo) { // click -> addEventListener
  this.$todos.next([...this.$todos.value, todo])
}

ngOnInit() {
  setTimeout() { // timer API
     this.$todos.next([...])
  }
}

Triggering CD

Marking dirty

Marking dirty

Angular’s reactivity used to be about streams

Reactivity ≠ Observables; it’s about state and dependencies

Signals

the new reactive primitive in Angular

Signals and Observables

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

const price = signal(100);
const quantity = signal(2);

const total = computed(() => price() * quantity());

effect(() => {
  console.log(`Total cost: $${total()}`);
});

price.set(120);   // Logs: "Total cost: $240"
quantity.update(q => q + 1); // Logs: "Total cost: $360"
const subject = new Subject<number>();

subject.subscribe(console.log);

subject.next(1);
subject.next(2);
subject.next(3);

console.log('logging here')
const mySignal = signal(0)

effect(() => console.log(mySignal()))

mySignal.set(1)
mySignal.set(2)
mySignal.set(3)

console.log('logging here')
1
2
3
logged here
logged here
3

It’s not just a new API — it’s a new "way of thinking"

Signals and Observables

SIGNALS OBSERVABLES
one value many values, over time
always Sync mainly async but could be sync
imperative API declarative API
explicit producer implicit producer
no subscription subscription need
auto memory management need to manually unsubsc

Signal Change Detection

Change detection wth Signals

Templates are now seen as effects

Road to fine grained CD

Fine grained reactivity

Zoneless today

OnPush + async pipe (or manual markForCheck)

bootstrapApplication(AppComponent, {
    providers: [
         provideZonelessChangeDetection(),
         provideBrowserGlobalErrorListeners()
    ]
});

*developer preview in v20

+ remove zone.js from polyfills (~19kb)

You don’t have to abandon RxJS — just use it where it makes sense.

New Signal APIs

New signal APIs

  • signal input/output
  • view & content queries
  • model
  • linkedSignal
  • resource & httpResource
  • routerOutletData & currentNavigation
  • signal forms 🧪
effect(() => {
  // Get the current navigation
  const navigation = this.router.getCurrentNavigation();

  if (navigation) {
    console.log('Current navigation:', navigation);
    console.log('Navigation extras state:', navigation.extras.state);
  } else {
    console.log('No current navigation (maybe page was refreshed).');
  }
})
count = input<number>
disabled = input<boolean, unknown>(false, { transform: booleanAttribute });

// @Input() count: number  
// @Input({ transform: booleanAttribute }) disabled

double = computed(() => {
  const disabled = this.disabled();
  const double = this.count() * 2
  return disabled ? 0 : double
})
// ngOnChanges ...
  
maxReached = output<void>()
//@Output maxReached = new EventEmitter()

maxReached.emit()
@Component({
  selector: 'custom-checkbox',
  template: '<div (click)="toggle()"> ... </div>',
})
export class CustomCheckbox {
  checked = model(false);

  toggle() {
    this.checked.set(!this.checked());
  }
}


@Component({
  template: '<custom-checkbox [(checked)]="isAdmin" />',
})
export class UserProfile {
  isAdmin = signal(false);
}
export class CounterComponent {
  // Base signal
  count = input(0);

  // Computed signal derived from count
  doubleCount = computed(() => this.count() * 2);

  // Linked signal: automatically mirrors the base signal
  doubleCountLinked = linkedSignal(() => this.count() * 2);

  increment() {
    // X - computeds are read-only 
    this.doubleCount.set(() => (this.doubleCount() + 1) * 2)
                         
    // Update linkedCount, automatically updates count too
    this.linkedCount.update(n => n + 1);
  }

  decrement() {
    this.linkedCount.update(n => n - 1);
  }
}
todoId = signal<number | null>(null);

// RESOURCE
singleTodo = resource({
    request: () => ({ id: this.todoId() }), // trigger for request
    loader: ({ request, abortSignal }) => {
      if (request.id) {
        return fetch(
          `https://jsonplaceholder.typicode.com/todos/${request.id}`,
          {
            signal: abortSignal,
          }
        ).then((response) => response.json());
      }
      return Promise.resolve({});
    },
  });

// HTTP RESOURCE
singleTodo = httpResource(
  () => `https://jsonplaceholder.typicode.com/todos/${this.todoId()}`, // trigger
  {
    map: (res: Todo): any => `<h1>${res.title}</h1>`,
    defaultValue: null,
  }
);

...about AI...

One last thing...

Thank you!

<= slides

Rethinking Reactivity

By Andrei Antal

Rethinking Reactivity

  • 15