Skip to main content
fast frontend

React and Angular: Reconciliation, OnPush, and the Re-renders Nobody Profiled

9 min read Chapter 31 of 33

React and Angular: Reconciliation, OnPush, and the Re-renders Nobody Profiled

The Re-render Tax

The e-commerce product listing page renders a grid of 60 product cards. When a user adds a product to the cart, a single state update triggers a re-render. In the default configuration, React re-renders every component in the tree that is a descendant of the state change. Angular runs change detection across every component bound to the default strategy.

The user clicked “Add to Cart” on one product. The state update changes the cart count from 3 to 4. This triggers:

React (unoptimized):

  • ProductListingPage re-renders (owns cart state)
  • Header re-renders (displays cart count, correct)
  • ProductGrid re-renders (child of page)
  • All 60 ProductCard components re-render (children of grid)
  • Each ProductCard re-renders ProductImage, ProductPrice, AddToCartButton (3 children each)
  • Total components re-rendered: 244
  • Total reconciliation time: 18ms

Angular (default change detection):

  • Zone.js intercepts the click event
  • ApplicationRef.tick() runs
  • Change detection visits every component in the component tree
  • 244 component checks, each comparing template bindings
  • Total change detection time: 14ms

18ms for React, 14ms for Angular, to update a cart badge from “3” to “4”. On a mid-range phone, these times double: 36ms and 28ms. Each re-render is a long task fragment that contributes to INP.

React: Profiling Re-renders

The React DevTools Profiler

// Enable profiler in development
import { Profiler, type ProfilerOnRenderCallback } from 'react';

const onRenderCallback: ProfilerOnRenderCallback = (
  id,
  phase,
  actualDuration,
  baseDuration,
  startTime,
  commitTime
) => {
  if (actualDuration > 5) {
    console.warn(
      `Slow render: ${id} (${phase}) took ${actualDuration.toFixed(1)}ms`
    );
  }
};

function ProductListingPage() {
  return (
    <Profiler id="ProductListing" onRender={onRenderCallback}>
      <Header />
      <ProductGrid />
    </Profiler>
  );
}

The Profiler reports actualDuration (time spent rendering this component and descendants) and baseDuration (estimated time to render without memoization). The phase indicates whether this is a “mount” (first render) or “update” (re-render).

Identifying Wasted Re-renders

The ProductGrid receives the same props before and after the cart update. Its product list has not changed. Its sort order has not changed. It re-renders because its parent re-rendered, and React re-renders all children by default.

// SLOW: ProductGrid re-renders on every cart state change
function ProductListingPage() {
  const [cartItems, setCartItems] = useState<CartItem[]>([]);
  const [products] = useState<Product[]>(initialProducts);
  const [sortBy, setSortBy] = useState<SortOption>("price-asc");

  const addToCart = useCallback((productId: string) => {
    setCartItems((prev) => [...prev, { productId, quantity: 1 }]);
  }, []);

  return (
    <>
      <Header cartCount={cartItems.length} />
      {/* ProductGrid re-renders when cartItems changes */}
      {/* Even though its props (products, sortBy, addToCart) haven't changed */}
      <ProductGrid
        products={products}
        sortBy={sortBy}
        onAddToCart={addToCart}
      />
    </>
  );
}

The issue: setCartItems triggers a re-render of ProductListingPage. React re-renders every child. ProductGrid receives the same products array, the same sortBy string, and the same addToCart function (stabilized with useCallback). It performs reconciliation on 60 product cards, diffs their virtual DOM trees, and determines that nothing changed. The result is correct (no DOM mutations), but the work is wasted.

React.memo with Measured Justification

// FAST: Memoized ProductGrid skips re-render when props are unchanged
const ProductGrid = memo(function ProductGrid({
  products,
  sortBy,
  onAddToCart,
}: ProductGridProps) {
  const sorted = useMemo(
    () => sortProducts(products, sortBy),
    [products, sortBy],
  );

  return (
    <div className="grid grid-cols-3 gap-4">
      {sorted.map((product) => (
        <ProductCard
          key={product.id}
          product={product}
          onAddToCart={onAddToCart}
        />
      ))}
    </div>
  );
});

const ProductCard = memo(function ProductCard({
  product,
  onAddToCart,
}: ProductCardProps) {
  return (
    <div className="rounded-lg border p-4">
      <img
        src={product.thumbnailUrl}
        alt={product.name}
        loading="lazy"
        width={280}
        height={280}
      />
      <h3 className="mt-2 font-medium">{product.name}</h3>
      <p className="text-lg font-bold">{formatPrice(product.price)}</p>
      <button
        onClick={() => onAddToCart(product.id)}
        className="mt-2 rounded bg-blue-600 px-4 py-2 text-white"
      >
        Add to Cart
      </button>
    </div>
  );
});

After memoization, the “Add to Cart” click triggers:

ComponentBefore memoAfter memo
ProductListingPageRe-renderRe-render
HeaderRe-renderRe-render
ProductGridRe-render (18ms)Skipped (0.1ms prop comparison)
60 ProductCardsRe-renderSkipped
Total re-render time18ms1.2ms

The 16.8ms saving occurs on every cart interaction. For a user who adds 5 items to the cart during a session, this saves 84ms of main thread time that would otherwise contribute to INP.

When Not to Memo

React.memo adds a shallow comparison of all props on every parent re-render. For components that always receive new props (new object references, inline callbacks), the comparison cost is paid without any benefit:

// SLOW: memo is wasteful here - style object is new every render
const BadMemo = memo(function Widget({ data }: { data: Data }) {
  return <div style={{ color: data.color }}>{data.label}</div>;
});

// The parent always passes a new object:
function Parent() {
  const [count, setCount] = useState(0);
  // New object reference every render - memo comparison always fails
  return <BadMemo data={{ color: "red", label: `Count: ${count}` }} />;
}

The decision rule: profile first. If the Profiler shows a component re-renders with actualDuration > 2ms and the re-render is triggered by unrelated state changes, React.memo is justified. If the component is cheap to render (<1ms) or always receives new props, memo adds cost without benefit.

Angular: OnPush Change Detection

Default vs OnPush

Angular’s default change detection strategy checks every component’s template bindings on every change detection cycle. Zone.js triggers change detection on every async event: click, HTTP response, setTimeout, setInterval.

// SLOW: Default change detection
// Every user interaction triggers change detection on this component
// Even if no inputs changed
@Component({
  selector: "app-product-card",
  template: `
    <div class="product-card">
      <img [src]="product.thumbnailUrl" [alt]="product.name" />
      <h3>{{ product.name }}</h3>
      <p>{{ product.price | currency }}</p>
      <button (click)="addToCart.emit(product.id)">Add to Cart</button>
    </div>
  `,
})
export class ProductCardComponent {
  @Input() product!: Product;
  @Output() addToCart = new EventEmitter<string>();
}

With default detection, every click anywhere on the page triggers Angular to check if product.thumbnailUrl, product.name, or product.price changed. For 60 product cards, that is 180 binding checks per user interaction.

// FAST: OnPush change detection
// Change detection runs only when inputs change by reference or events fire
@Component({
  selector: "app-product-card",
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="product-card">
      <img [src]="product.thumbnailUrl" [alt]="product.name" />
      <h3>{{ product.name }}</h3>
      <p>{{ product.price | currency }}</p>
      <button (click)="addToCart.emit(product.id)">Add to Cart</button>
    </div>
  `,
})
export class ProductCardComponent {
  @Input() product!: Product;
  @Output() addToCart = new EventEmitter<string>();
}

With OnPush, Angular skips change detection for ProductCardComponent unless:

  1. An @Input() property receives a new reference (not deep equality, reference equality)
  2. An event handler fires within the component
  3. An Observable bound with async pipe emits

The product list does not change when a user adds to cart. With OnPush on all 60 product card components, Angular skips 180 binding checks.

Signal-Based Reactivity

Angular Signals provide fine-grained reactivity without Zone.js:

// Angular Signals: surgical updates
@Component({
  selector: "app-product-listing",
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <app-header [cartCount]="cartCount()" />
    <div class="grid grid-cols-3 gap-4">
      @for (product of products(); track product.id) {
        <app-product-card
          [product]="product"
          (addToCart)="handleAddToCart($event)"
        />
      }
    </div>
  `,
  imports: [HeaderComponent, ProductCardComponent],
})
export class ProductListingComponent {
  private readonly cartService = inject(CartService);

  products = signal<Product[]>([]);
  cartCount = computed(() => this.cartService.items().length);

  handleAddToCart(productId: string): void {
    this.cartService.addItem(productId);
    // Only cartCount signal updates
    // products signal unchanged = product cards not re-checked
  }
}

// Cart service with signals
@Injectable({ providedIn: "root" })
export class CartService {
  items = signal<CartItem[]>([]);

  addItem(productId: string): void {
    this.items.update((items) => [...items, { productId, quantity: 1 }]);
  }
}

With signals, adding to cart updates only cartCount. The products signal reference does not change, so the @for loop does not re-check product cards. The header re-renders because cartCount() changed.

Change detection comparison for “Add to Cart”:

StrategyComponents checkedBindings evaluatedTime
Default24473214ms
OnPush (all components)380.8ms
Signals + OnPush240.4ms

Angular Profiling

// Angular DevTools profiler equivalent: manual instrumentation
import { AfterViewChecked, Component } from "@angular/core";

@Component({
  selector: "app-product-grid",
  // ...
})
export class ProductGridComponent implements AfterViewChecked {
  private checkCount = 0;

  ngAfterViewChecked(): void {
    this.checkCount++;
    if (this.checkCount % 100 === 0) {
      console.log(`ProductGrid checked ${this.checkCount} times`);
    }
  }
}

In production, the Angular DevTools “Profiler” tab shows change detection cycles, component check counts, and time per cycle. A component with high check counts and OnPush disabled is a memoization candidate.

Framework-Agnostic Performance Rules

Both React and Angular share the same underlying performance constraint: the browser’s main thread. Both frameworks perform reconciliation/change detection synchronously on the main thread. Both compete with layout, paint, and input handling for the same 16.67ms frame budget (60fps).

Three rules apply regardless of framework:

1. Lift state to the narrowest shared ancestor. If only the header needs cart count, cart state should live in a context/service, not in the page component that wraps the product grid.

2. Stabilize references. Functions, objects, and arrays passed as props/inputs must maintain reference identity across renders unless their value changed. In React, this means useCallback and useMemo. In Angular, this means immutable data patterns with OnPush.

3. Profile before optimizing. The React Profiler and Angular DevTools Profiler show which components re-render and how long each takes. Adding memo or OnPush to a component that takes <1ms to render saves nothing and adds maintenance cost.

The CI gate from Chapter 2 catches INP regressions caused by excessive re-renders. A Lighthouse audit with simulated throttling surfaces long tasks that include framework reconciliation work. The total blocking time metric in the CI report is the aggregate of all long task overruns, including re-render time.

For the e-commerce platform:

  • Product listing: memo / OnPush on ProductCard (60 instances, 18ms → 1.2ms on cart update)
  • Product detail: memo on Reviews and Recommendations (stream independently, heavy render)
  • Checkout: No memoization needed (small component tree, all components depend on form state)
  • Inventory dashboard: Signals in Angular, useSyncExternalStore in React (real-time data, frequent updates)