The single most important factor that differentiates front-end frameworks
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() {<br>const [count, setCount] = useState(0);<br>return (<br>div><br>button onClick={() => setCount(count - 1)}>decrementbutton><br>span>{count}span><br>button onClick={() => setCount(count + 1)}>incrementbutton><br>button onClick={() => setTimeout(() => setCount(count + 1), 1000)}>increment laterbutton><br>div><br>);
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({<br>selector: 'counter',<br>template: `
decrement<br>{{ count }}<br>increment<br>increment later
})<br>export class Counter {<br>count = 0;
incrementLater() {<br>setTimeout(() => {<br>this.count++;<br>}, 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({<br>selector: 'counter',<br>template: `
decrement<br>{{ count }}<br>increment<br>increment later
`,<br>changeDetection: ChangeDetectionStrategy.OnPush<br>})<br>export class Counter {<br>constructor(private readonly cdr: ChangeDetectorRef) {}
count = 0;
incrementLater() {<br>setTimeout(() => {<br>this.count++;<br>this.cdr.markForCheck();<br>}, 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 {<br>INCREMENT,<br>DECREMENT,<br>INCREMENT_LATER
@Component({<br>selector: 'counter',<br>template: `
decrement<br>{{ count | async }}<br>increment<br>increment later
`,<br>changeDetection: ChangeDetectionStrategy.OnPush<br>})<br>export class Counter {<br>readonly update = new SubjectAction>();
readonly count = this.update.pipe(<br>switchScan((prev, action) => {<br>switch (action) {<br>case Action.INCREMENT:<br>return of(prev + 1);<br>case Action.DECREMENT:<br>return of(prev - 1);<br>case Action.INCREMENT_LATER:<br>return of(prev +...