Angular's introduction of signals has marked a significant shift in the framework, influencing everything from state management libraries to the fundamental approach developers take toward reactivity. The ripple effects are felt across the entire ecosystem.
However, while most signal primitives have reached a stable state, effect remains in developer preview at the time of writing. This means it's still prone to generating errors or warnings in certain scenarios.
This article explores the fundamentals of using effect, shares practical tips I've gathered, and demonstrates a method to extend its functionality.
The Essence of Effects
An effect is a function that sets up a callback, which gets executed each time any signal accessed within that callback undergoes a change:
const age = signal(26);
effect(() => {
console.log(`Happy ${age()}th birthday! 🎂`);
});
age.update(age => ++age);
// -> Happy 27th birthday! 🎂
Practical Recommendations
The basic structure of an effect is simple, but real-world applications often reveal complexities that can lead to subtle bugs and maintenance headaches.
Through extensive use, the Angular community has developed several patterns to manage these challenges. Here are a few that I've found particularly effective.
📝 These are simply personal preferences, not official guidelines or mandated conventions.
Make Dependencies Clear
In a minimal example, it's easy to see that an effect depends on the age signal, as there's only one line and one signal involved.
But in a sprawling codebase, this isn't always so obvious, adding unnecessary mental strain.
A common and effective practice is to explicitly extract the signal values at the top of the effect's callback. This clarifies dependencies and makes the effect self-documenting:
effect(() => {
// 👇 Indicate which signals this effect will work on
const productId = productId();
const isLoggedIn = isLoggedIn();
// Logic based on the unwrapped values
});
Keep Effects Focused
Aim to create Small and Focused Effects, adhering to the SaFE principle.
When effects become too large, their original purpose gets muddled. Adding another signal dependency later can also introduce unintended consequences.
Short, focused effects are easier to understand and maintain, with a clearly defined scope:
// ❌
effect(() => {
const age = age();
const isLoggedIn = isLoggedIn();
const selectedProductId = selectedProductId();
// ...
});
// ✅
effect(() => {
const isLoggedIn = isLoggedIn();
// ...
});
effect(() => {
const age = age();
const selectedProductId = selectedProductId();
// ...
});
Give It a Name
Effects are essentially callbacks, which can make their intention unclear at a glance.
Luckily, the API returns an EffectRef that we can assign a descriptive name to, which clearly states the effect's purpose.
Take our earlier example, for instance:
const redirectAnonymousEffect = effect(() => {
const isLoggedIn = isLoggedIn();
// ...
});
const ensureLegalAgeEffect = effect(() => {
const age = age();
const selectedProductId = selectedProductId();
// ...
});
📝 A good naming practice can also help you realize that a single effect is trying to do *too much* and should be broken down into smaller, more manageable pieces.
Exclude Non-Reactive Logic
Because effects automatically track all signals read within a reactive context, it's sometimes beneficial to be even more explicit than just declaring dependencies. You can use untracked to ensure that certain signal reads do not become dependencies of the effect:
const ensureLegalAgeEffect = effect(() => {
const age = age();
const selectedProductId = selectedProductId();
// 👇 Nothing in here can impact the trigger of the effect
untracked(() => {
// ...
});
});
While this approach adds a bit of verbosity, it offers a robust guarantee that specific logic won't inadvertently trigger the effect.
Enhancing Effect Functionality
Knowing and applying these tips is one thing, but maintaining consistency through discipline alone can be tough, particularly within a team setting.
Fortunately, with some clever TypeScript, a few insightful GitHub discussions, and a bit of coding effort, we can build a wrapper function that encapsulates these best practices:
This wrapper takes a list of signals to watch and a function to run when they change. Any other signals read inside that function are automatically excluded from the effect's dependency graph:
const loginChangedEffect = effectFromDeps(
[this.loginStatus],
([loginStatus]) => {
console.log(`[${loginStatus}] There is currently ${this.userCount()} users`)
});
We can take this a step further by adding an optional configuration object, which provides more granular control over the effect's lifecycle:
This extended version enables us to define callbacks for when the effect is first created and when it's subsequently cleaned up:
const loginChangedEffect = effectFromDepsWithLifecycle(
[this.loginStatus],
([loginStatus]) => {
console.log(`[${loginStatus}] There is currently ${this.userCount()} users`)
}, {
onCreation: () => console.log('Created'),
onCleanup: () => console.log('Cleaned up'),
});
That's it for today. Happy coding!
