DEV Community

Cover image for The future of State Management in LWC: Understanding Signals
Leandro Brunner
Leandro Brunner

Posted on • Edited on

The future of State Management in LWC: Understanding Signals

In Salesforce Lightning Web Components (LWC), we have powerful features like @track, @wire, Custom Events, and Lightning Message Service (LMS) that work effectively. However, these tools often require significant additional effort when managing complex states or sharing data between multiple components.

What are Signals?

Signals is a concept utilized by numerous modern libraries and frameworks including SolidJs, Preact, React, and Angular. It enables automatic reactivity in all locations when a value changes from any source.

This isn't a new concept - KnockoutJs implemented this mechanism in their observables back in 2010.

While each Signal implementation differs, the core concept remains consistent across frameworks.

How is this related to Salesforce?

Salesforce is currently experimenting with the Signals concept for LWC. We can explore its potential implementation by examining this package: https://www.npmjs.com/package/@lwc/signals

The implementation closely mirrors Preact Signals (https://github.com/preactjs/signals).

It introduces a primitive signal() with a .value property that can be accessed and modified. Components then react to changes and re-render, similar to when using a @track property.

import { signal } from 'some/signals';

export default class ExampleComponent extends LightningElement {
    count = signal(0);

    increment() {
        this.count.value++;
    }
}
Enter fullscreen mode Exit fullscreen mode
<template>
    <button onclick="{increment}">Increment</button>
    <p>{count.value}</p>
</template>
Enter fullscreen mode Exit fullscreen mode

Additionally, there's a subscribe() method that enables notifications about value changes from a signal.

const firstName = signal("Joe");

firstName.subscribe(() => {
  console.log(`First Name new value: ${firstName.value}`);
});

firstName.value = "John";
Enter fullscreen mode Exit fullscreen mode
First Name new value: John
Enter fullscreen mode Exit fullscreen mode

Whats the difference with @track?

Salesforce LWC automatically reacts to property changes - you don't even need @track anymore.

In this example, both properties (firstName and lastName) are reflected in the template when their values change.

// example/example.js

export default class Example extends LightningComponent {
    @track firstName; // <-- tracked
    lastName; // <-- tracked

    handleFirstNameChange(event) {
        this.firstName = event.detail.value;
    }

    handleLasttNameChange(event) {
        this.lastName = event.detail.value;
    }
}
Enter fullscreen mode Exit fullscreen mode
<!-- example/example.html -->

<template>
    <div>
        <lightning-input type="text" label="First Name" onchange={handleFirstNameChange}></lightning-input>
    </div>
    <div>
        <lightning-input type="text" label="Last Name" onchange={handleLastNameChange}></lightning-input>
    </div>

    <div>Full Name: {firstName} {lastName}</div>
</template>
Enter fullscreen mode Exit fullscreen mode

But there are some limitations

To achieve reactivity for properties, they must be declared first.

For instance, in this case, changes to lastName are not reflected:

// example/example.js

export default class Example extends LightningComponent {
    firstName; // <-- tracked

    handleFirstNameChange(event) {
        this.firstName = event.detail.value;
    }

    handleLastNameChange(event) {
        this.lastName = event.detail.value; // <-- not tracked
    }
}
Enter fullscreen mode Exit fullscreen mode

Furthermore, sharing and reflecting state between components presents challenges.

Let's attempt to share our state with a child component:

// parent/parent.js

export default class Parent extends LightningComponent {
    firstName;
    lastName;

    handleFirstNameChange(event) {
        this.firstName = event.detail.value;
    }

    handleLastNameChange(event) {
        this.lastName = event.detail.value;
    }
}
Enter fullscreen mode Exit fullscreen mode
<!-- parent/parent.html -->

<template>
    <div>
        <lightning-input type="text" label="First Name" onchange={handleFirstNameChange}></lightning-input>
    </div>
    <div>
        <lightning-input type="text" label="Last Name" onchange={handleLastNameChange}></lightning-input>
    </div>

    <c-child first-name={firstName} last-name={lastName}></c-child>
</template>
Enter fullscreen mode Exit fullscreen mode

Here, we need to pass the tracked properties to the child component, which can then receive them via @api.

// child/child.js

export default class Child extends LightningComponent {
    @api firstName = "";
    @api lastName = "";

    get fullName() {
        return `${this.firstName} ${this.lastName}`;
    }
}
Enter fullscreen mode Exit fullscreen mode
<!-- child/child.html -->

<template>
    <div>Full Name: {fullName}</div>
</template>
Enter fullscreen mode Exit fullscreen mode

However, a challenge arises when trying to modify the state from the child component:

// child/child.js

export default class Child extends LightningComponent {
    @api firstName = "";
    @api lastName = "";

    get fullName() {
        return `${this.firstName} ${this.lastName}`;
    }

    handleClearName() {
        this.firstName = ""; // <-- fails
        this.lastName = ""; // <-- fails
    }
}
Enter fullscreen mode Exit fullscreen mode
<!-- child/child.html -->

<template>
    <div>Full Name: {fullName}</div>

    <div>
        <lightning-button label="Clear Name" onclick={handleClearName}></lightning-button>
    </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Directly overriding an @api property's value is not possible.

We can solve this using custom events:

// child/child.js

export default class Child extends LightningComponent {
    @api firstName = "";
    @api lastName = "";

    get fullName() {
        return `${this.firstName} ${this.lastName}`;
    }

    handleClearName() {
        this.dispatchEvent(new CustomEvent("clearname"));
    }
}
Enter fullscreen mode Exit fullscreen mode
// parent/parent.js

export default class Parent extends LightningComponent {
    firstName;
    lastName;

    handleFirstNameChange(event) {
        this.firstName = event.detail.value;
    }

    handleLastNameChange(event) {
        this.lastName = event.detail.value;
    }

    handleClearName() {
        this.firstName = "";
        this.lastName = "";
    }
}
Enter fullscreen mode Exit fullscreen mode
<!-- parent/parent.html -->

<template>
    <div>
        <lightning-input type="text" label="First Name" onchange={handleFirstNameChange}></lightning-input>
    </div>
    <div>
        <lightning-input type="text" label="Last Name" onchange={handleLastNameChange}></lightning-input>
    </div>

    <c-child first-name={firstName} last-name={lastName} onclearname={handleClearName}></c-child>
</template>
Enter fullscreen mode Exit fullscreen mode

Alternatively, we could declare a Message Channel, add a @wire property, publish a message, and so on.

Now, imagine implementing this in a large-scale application with complex state management requirements - the code becomes increasingly difficult to maintain and implement effectively.

Signals to the rescue!

This is where Signals truly shines! Let's refactor the code to utilize signals:

// parent/signals.js

import { signal } from 'some/signals';

export const firstName = signal();
export const lastName = signal();
Enter fullscreen mode Exit fullscreen mode
// parent/parent.js

import { firstName, lastName } from "./signals";

class Parent extends LightningComponent {
    handleFirstNameChange(event) {
        firstName.value = event.detail.value;
    }

    handleLastNameChange(event) {
        lastName.value = event.detail.value;
    }
}
Enter fullscreen mode Exit fullscreen mode
<!-- parent/parent.html -->

<template>
    <div>
        <lightning-input type="text" label="First Name" onchange={handleFirstNameChange}></lightning-input>
    </div>
    <div>
        <lightning-input type="text" label="Last Name" onchange={handleLasttNameChange}></lightning-input>
    </div>

    <c-child></c-child>
</template>
Enter fullscreen mode Exit fullscreen mode
// child/child.js

import { firstName, lastName } from "c/parent/signals";

export default class Child extends LightningComponent {
    get fullName() {
        return `${firstName.value} ${lastName.value}`;
    }

    handleClearName() {
        firstName.value = null;
        lastName.value = null;
    }
}
Enter fullscreen mode Exit fullscreen mode
<!-- child/child.html -->

<template>
    <div>Full Name: {fullName}</div>

    <div>
        <lightning-button label="Clear Name" onclick={handleClearName}></lightning-button>
    </div>
</template>
Enter fullscreen mode Exit fullscreen mode

That's significantly more straightforward!

In this new implementation, Signals can be shared between components, and the components react automatically when a signal value changes, requiring minimal additional effort.

Can I use signals in my LWC projects?

The answer is both no and yes!

Native Signal support for LWC remains in the conceptual experimental phase and isn't yet available.

However, you can leverage external libraries to implement the Signals concept today.

Introducing lwc-signals!

Due to my enthusiasm for implementing Signals in my projects, I created a custom implementation for LWC.

Github Repo: https://github.com/leandrobrunner/lwc-signals

NPM Package: https://www.npmjs.com/package/lwc-signals

This library provides a comprehensive Signals implementation inspired by Preact Signals), featuring:

  • Computed values
  • Effects
  • Batch updates
  • Deep reactivity
  • Manual subscriptions
  • Design aligned with Salesforce's signals concept for future compatibility

How it works?

The implementation features a straightforward reactive system.

Signals Flow Diagram

  • Signals & Computed: Notify subscribers when values change
  • Effects: Subscribe to signals and run when changes occur

The library includes a WithSignals Mixin that enables LWC components to react to signal changes.

Mixin Flow Diagram

  • WithSignals: Uses an internal effect to track signal dependencies
  • Render Process:
    • Captures which signals are used
    • Reads internal __updateTimestamp property
    • __updateTimestamp becomes a dependency
  • Updates: Changes to signals trigger timestamp update, causing re-render

Examples

Basic Component

import { LightningElement } from 'lwc';
import { WithSignals, signal } from 'c/signals';

export default class Counter extends WithSignals(LightningElement) {
    count = signal(0);

    increment() {
        this.count.value++;
    }

    get doubleCount() {
        return this.count.value * 2;
    }
}
Enter fullscreen mode Exit fullscreen mode
<template>
    <div>
        <p>Count: {count.value}</p>
        <p>Double: {doubleCount}</p>
        <button onclick={increment}>Increment</button>
    </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Parent-Child Communication

// parent.js
import { LightningElement } from 'lwc';
import { WithSignals, signal } from 'c/signals';

// Signal shared between components
export const parentData = signal('parent data');

export default class Parent extends WithSignals(LightningElement) {
    updateData(event) {
        parentData.value = event.target.value;
    }
}
Enter fullscreen mode Exit fullscreen mode
<!-- parent.html -->
<template>
    <div>
        <input value={parentData.value} onchange={updateData} />
        <c-child></c-child>
    </div>
</template>
Enter fullscreen mode Exit fullscreen mode
// child.js
import { LightningElement } from 'lwc';
import { WithSignals } from 'c/signals';
import { parentData } from './parent';

export default class Child extends WithSignals(LightningElement) {
    // Use the shared signal directly
    get message() {
        return parentData.value;
    }
}
Enter fullscreen mode Exit fullscreen mode
<!-- child.html -->
<template>
    <div>
        Message from parent: {message}
    </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Global State

// store/userStore.js
import { signal, computed } from 'c/signals';

export const user = signal({
    name: 'John',
    theme: 'light'
});

export const isAdmin = computed(() => user.value.role === 'admin');

export const updateTheme = (theme) => {
    user.value.theme = theme;
};
Enter fullscreen mode Exit fullscreen mode
// header.js
import { LightningElement } from 'lwc';
import { WithSignals } from 'c/signals';
import { user, updateTheme } from './store/userStore';

export default class Header extends WithSignals(LightningElement) {
    // You can access global signals directly in the template
    get userName() {
        return user.value.name;
    }

    get theme() {
        return user.value.theme;
    }

    toggleTheme() {
        updateTheme(this.theme === 'light' ? 'dark' : 'light');
    }
}
Enter fullscreen mode Exit fullscreen mode
// settings.js
import { LightningElement } from 'lwc';
import { WithSignals } from 'c/signals';
import { user, isAdmin } from './store/userStore';

export default class Settings extends WithSignals(LightningElement) {
    // Global signals and computed values can be used anywhere
    get showAdminPanel() {
        return isAdmin.value;
    }

    updateName(event) {
        user.value.name = event.target.value;
    }
}
Enter fullscreen mode Exit fullscreen mode

Deep Reactivity

const user = signal({
    name: 'John',
    settings: { theme: 'dark' }
});

// Direct property mutations work!
user.value.settings.theme = 'light';

const list = signal([]);
// Array methods are fully reactive
list.value.push('item');
list.value.unshift('first');
list.value[1] = 'updated';
Enter fullscreen mode Exit fullscreen mode

Summary

Signals provide a powerful and elegant solution for state management in LWC, simplifying component communication and reducing boilerplate code. While we await native support from Salesforce, the lwc-signals library brings this functionality to your projects today.

The project is available on Github and is open for contributions and feedback.

Happy coding!

Top comments (0)