https://themer.dev/blog/the-single-most-important-factor-that-differentiates-front-end-frameworks
<- back to themer
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() {
const [count, setCount] = useState(0);
return (
{count}
);
}
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();
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 way as React; the framework is taking
control over the application's state so that changes can be easily
monitored and re-renders can be as efficient as possible.
From the Angular docs:
Angular Signals is a system that granularly tracks how and where
your state is used throughout an application, allowing the
framework to optimize rendering updates.
This is a large paradigm shift, making Angular applications more
similar to the other frameworks.
Conclusion
Angular's change detection is a disaster. The developer gets two
suboptimal choices: (1) the slow and naive default implementation, or
the complexity of managing change detection manually. Signals will
make it much better, though nearly a decade too late.
Vue
"I'll track changes to state and react accordingly." --Vue
Vue's approach to change detection is subtly different than both
React and Angular. Rather than calling a framework function to change
state (React) or changing state and then informing the framework that
it has been changed (Angular), you work with state objects that have
been specially instrumented by the framework to intercept and detect
changes.
Confusingly, Vue has two different APIs that wrap the same underlying
change detection engine differently. Under the "Options API," you
define an object that contains your state, and Vue assigns a proxied
version of that object as a member of this for use in the component's
functions:
{{ count }}
Alternatively, the "Composition API" is somewhat similar to React's
hooks: a framework function is called to retrieve a state object that
Vue can monitor for changes:
{{ count }}
Conceptually, the object returned from ref() has a getter and a
setter for value, which allows Vue to track changes to it.
Conclusion
Vue utilizes JavaScript language features to allow developers to work
with stateful variables without thinking about change detection.
Svelte
"I'll figure it out for you at compile time." --Svelte
On the surface, Svelte's version of our counter component looks
pretty similar to the other frameworks:
{count}
But Svelte's approach to change detection is completely novel in
comparison. At compile time, Svelte analyzes an AST (Abstract Syntax
Tree) of the component's code and injects some code into the compiled
output that surgically updates the DOM when necessary. For example,
here is what the compiled decrement() function looks like:
function decrement() {
$$invalidate(0, count--, count);
}
Where $$invalidate is a call to Svelte's internals to instruct the
compiled component to update the DOM.
This compile-time approach means that Svelte applications don't need
to bundle a large runtime along with the application itself.
Conclusion
Svelte strikes a rare win-win balance: developers don't have to think
about change detection at all, and can interact with stateful
variables intuitively; yet the end user's experience is improved
through better performance because a bare-minimum application (with
change detection baked in) is shipped to the browser.
So, what?
The nuances of how various frameworks choose to tame this beast is
not limited to how things work at the component level; it ripples out
to everything else about the framework. To name just a few examples:
the concepts used to create custom React hooks composed of the basic
hooks provided by React out of the box are not relevant to
generalizing component behavior in Vue; the challenge of working with
observables for state management in Angular has led folks to try and
find ways to convert component input props to observables; the
framework's API, dictated by its change detection management
paradigm, affects how well it integrates with productivity and
quality tools like typechecking, testing, and linting. And so on, and
so forth.
And those are just examples from the developer's point of view. Each
approach has implications on the performance of the application for
the end user. React, Vue, and Angular each ship a runtime to the
user's browser that needs to be parsed and executed. Svelte's choice
to be a compile-time framework obviates this need in most cases, so
the user gets a faster loading experience. Each framework has
subtleties that make it more susceptible to particular classes of
bugs (often around state management or change detection) that the end
user will experience.
Find a change detection paradigm that fits the needs of your
application, and everything else will fall into place. Pick one that
doesn't work, and you'll be fighting against it for the life of the
project.
theme: "Default" theme: "Default" // join the newsletter // Mastodon
// GitHub