There are tons of blog posts on the internet about how frameworks differ and which one to pick for your next web project. Usually they cover a few aspects of the framework like syntax, development setup, and community size.
This isn’t one of those posts.
Instead, we’ll go directly to the crux of the main problem front-end frameworks set out to solve: change detection, meaning detecting changes to application state so that the UI can be updated accordingly. Change detection is the fundamental feature of front-end frameworks, and the framework authors’ solution to this one problem determines everything else about it: developer experience, user experience, API surface area, community satisfaction and involvement, etc., etc.
And it turns out that examining various frameworks from this perspective will give you all of the information you need to determine the best choice for you and for your users. So let’s dive deep into how each framework tackles change detection.
Major frameworks compared
We’ll look at each of the major players and how they have tackled change detection, but the same critical eye can apply to any front-end JavaScript framework you may come across.
React
“I’ll manage state so that I know when it changes.” —React
True to its de-facto tagline, change detection in React is “just JavaScript.” Developers simply update state by calling directly into the React runtime through its API; since React is notified to make the state change, it also knows that it needs to re-render the component.
Over the years, the default style for writing components has changed (from class components and pure components to function components to hooks) but the core principle has remained the same. Here’s an example component that implements a button counter, written in the hooks style:
export default function App() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count - 1)}>decrementbutton>
<span>{count}span>
<button onClick={() => setCount(count + 1)}>incrementbutton>
<button onClick={() => setTimeout(() => setCount(count + 1), 1000)}>increment laterbutton>
div>
);
}
The key piece here is the setCount
function returned to us by React’s useState
hook. When this function is called, React can use its internal virtual DOM diffing algorithm to determine which pieces of the page to re-render. Note that this means the React runtime has to be included in the application bundle downloaded by the user.
Conclusion
React’s change detection paradigm is straightforward: the application state is maintained inside the framework (with APIs exposed to the developer for updating it) so that React knows when to re-render.
Angular
“I’ll make the developer do all the work.” —Angular
When you scaffold a new Angular application, it appears that change detection happens automagically:
@Component({
selector: 'counter',
template: `
{{ count }}
`
})
export class Counter {
count = 0;
incrementLater() {
setTimeout(() => {
this.count++;
}, 1000);
}
}
What’s really happening, is that Angular uses NgZone
to observe user actions, and is checking your entire component tree on every event.
For applications of any reasonable size, this causes performance issues, since checking the entire tree quickly becomes too costly. So Angular provides an escape hatch from this behavior by allowing the developer to choose a different change detection strategy: OnPush
. OnPush
means that the onus is on the developer to inform Angular when state changes so that Angular can re-render the component. Aside from the default naive strategy, OnPush
is the only other change detection strategy Angular offers. With OnPush
enabled, we must manually tell Angular’s change detector to check the new state if it ever gets updated asynchronously:
@Component({
selector: 'counter',
template: `
{{ count }}
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class Counter {
constructor(private readonly cdr: ChangeDetectorRef) {}
count = 0;
incrementLater() {
setTimeout(() => {
this.count++;
this.cdr.markForCheck();
}, 1000);
}
}
For applications of any reasonable complexity, this approach quickly becomes untenable.
Alternative solutions are introduced to wrangle this problem. The primary one that the Angular docs suggest is to use RxJS observables in conjunction with the AsyncPipe
:
enum Action {
INCREMENT,
DECREMENT,
INCREMENT_LATER
}
@Component({
selector: 'counter',
template: `
{{ count | async }}
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class Counter {
readonly update = new Subject<Action>();
readonly count = this.update.pipe(
switchScan((prev, action) => {
switch (action) {
case Action.INCREMENT:
return of(prev + 1);
case Action.DECREMENT:
return of(prev - 1);
case Action.INCREMENT_LATER:
return of(prev + 1).pipe(delay(1000));
}
}, 0),
startWith(0)
);
readonly Action = Action;
}
Under the hood, AsyncPipe
takes care of subscribing to the observable, informing the change detector when the observable emits a new value, and unsubscribing when the component is destroyed. Observables are a powerful way to model state changes over time, but they come with some serious drawbacks:
- They are difficult to debug.
- They have a very steep learning curve.
- They are great for modeling streams of values (think: mouse movements), but they are overkill for the more common use cases (simple state changes like the on/off state of a checkbox).
To overcome the shortcomings of the default change detection paradigm, the Angular team is working on a new approach called Signals. Conceptually, signals are similar to Svelte stores (which we’ll get to later), and fundamentally, they solve the change detection problem the same