Unlocking Greater Performance: The Power of Signals for State Handling
Signals were developed for Preact but can also be used with React, delivering a great performance, as this article will show.
Discover how at OpenReplay.com.
React’s Virtual DOM is a valuable feature that simplifies application development by efficiently handling complicated updates. However, as applications scale, handling state becomes increasingly challenging, resulting in performance difficulties and inefficient re-renders. Traditional methods, such as hooks(useMemo
, useCallback
, etc.), avoiding reconciliation, and Server Side Rendering (SSR), can be complex and difficult to deploy, particularly in larger applications.
Enter Signals, a cutting-edge state-handling solution in React applications. Signals, offered by Preact, provide a more precise and optimized way to manage state, enabling developers to easily build performant and scalable React apps.
This article will explore Signals, their integration with React via @preact/signals
, and how they exceed React’s State. We will present code samples that demonstrate granular updates and re-render prevention.
Prerequisites:
- Basic understanding of React and JavaScript
- Familiarity with state management in React
- Basic understanding of Tailwind CSS (Optional)
Ready to master Signals and elevate your React development skills? Let’s dive in!
Signals in React
Signals are simple JavaScript objects that hold a value and a function to update that value.
Signals were initially introduced by the Svelte framework. Preact, a lightweight alternative to React, has adopted Signals and integrated them with React via the @preact/signals
package. This integration allows developers to use Signals in their React projects, providing a more efficient way to manage state and improve performance.
To learn more about preparing your project for your target environment, see Preact’s Getting Started Guide.
To understand the efficiency gains of using Signals, let’s compare them to React’s built-in State management.
Comparison between React and Signals
In this section, we compare React’s State and Signals to highlight the efficiency gain below:
React State | Signals |
---|---|
A state is an object that holds data relevant to a component. | Signals are individual pieces of state, making it easier to manage granular updates. |
When a state changes, React re-renders the component and its children, which can lead to unnecessary re-renders and performance issues. | When a Signal updates, only the components that directly depend on that Signal are re-rendered, reducing unnecessary re-renders and improving performance. |
To optimize re-rendering, developers often use hooks like useMemo and useCallback , which can be complex and difficult to manage. | Signals can be easily shared between components, making managing state across the application simple. |
State vs. Signal Implementation
In light of the discussion above, let’s delve deeper into the comparative analysis of state and signal implementations. Understanding the intricacies of both approaches will arm developers with the knowledge to make informed decisions for their React applications, balancing performance with complexity.
React’s state management is powerful but can be heavy-handed regarding re-rendering components. A typical scenario can be observed in a notification badge component. Using React’s useState, a developer maintains an array of notifications and an updater function. React re-renders the entire component when a new notification arrives and the state updates. This is efficient for initial development, but as the application grows, it can result in performance issues. Excessive re-renders can tax the browser, lead to janky UIs, and, ultimately, a degraded user experience.
import { useState } from 'react';
function NotificationBadgeReact() {
console.log('rerendering');
const [notifications, setNotifications] = useState([]);
function addNotification() {
setNotifications(notifications.concat('New notification'));
}
return (
<>
<h2 className='text-3xl text-center font-bold mb-6'>React State</h2>
<hr className='mb-10' />
<div className='flex items-center justify-center gap-5'>
<button
onClick={addNotification}
className='bg-blue-500 hover:bg-blue-700 hover:scale-105 transition ease-in-out duration-300 text-white font-bold py-2 px-4 rounded text-xl'>
Add Notification
</button>
<div className='relative'>
<img src='/bell.svg' alt='bell' className='w-10 h-10' />
<span className='bg-red-500 text-white w-7 h-7 flex text-lg items-center justify-center rounded-full ml-2 absolute -top-2 -right-2'>
{notifications.length}
</span>
</div>
</div>
<div className='mt-6'>
<h2 className='text-center font-semibold text-xl'>Notifications</h2>
<div className='mx-auto text-lg'>{notifications}</div>
</div>
</>
);
}
export default NotificationBadgeReact;
In the provided React implementation code above, every click on the “Add Notification” button potentially triggers a cascade of re-renders, as seen below, which, while invisible to the user, can be costly in terms of performance.
As introduced by Preact, signals offer a more fine-grained control over the re-rendering process. A signal is an independent entity that can be updated without re-rendering the entire component. This means that updates can be propagated only to the UI parts dependent on the changed signal. For the same notification badge component, this would mean that only the count of notifications would update, leaving the rest of the component untouched.
For this example, we would be using @preact/signals-react
which is a React integration. More information about the package can be found here.
import { signal } from '@preact/signals-react';
function NotificationBadge() {
console.log('rerendering');
// Signal to store notifications and notification count
const notifications = signal([]);
const notificationCount = signal(notifications.value.length);
function addNotification() {
notifications.value = notifications.value.concat('New notification');
notificationCount.value = notifications.value.length;
}
return (
<>
<h2 className='text-3xl text-center font-bold mb-6'>Signals</h2>
<hr className='mb-10' />
<div className='flex items-center justify-center gap-5'>
{/* Button to click to increase notification count */}
<button
onClick={addNotification}
className='bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded text-xl'>
Add Notification
</button>
{/* Notification Badge */}
<div className='relative'>
<img src='/bell.svg' alt='bell' className='w-10 h-10' />
<span className='bg-red-500 text-white w-7 h-7 flex text-lg items-center justify-center rounded-full ml-2 absolute -top-2 -right-2'>
{notificationCount}
</span>
</div>
</div>
<div className='mt-6'>
<h2 className='text-center font-semibold text-xl'>Notifications</h2>
<div className='mx-auto text-lg'>{notifications}</div>
</div>
</>
);
}
export default NotificationBadge;
This code shows the signal-based implementation. Here, the signal function creates a reactive primitive. When the addNotification function is called, it updates the notification count signal, and only the UI elements subscribed to that signal get updated, as seen below. This can drastically reduce the number of re-renders, improving performance, especially in complex applications.
Benefits of Using Signals
The introduction of Signals into the state management landscape brings a multitude of benefits that extend beyond the technical aspects of re-render optimization. This section of our article focuses on the advantages of employing Signals in React applications, particularly emphasizing their efficiency, precision, and optimization capabilities, especially in large-scale projects.
Efficiency Beyond Avoiding Re-renders
One of the primary benefits of using Signals is the overall boost in application performance. It’s a common misconception that the only performance gain from Signals comes from avoiding unnecessary re-renders. While that’s a significant benefit, Signals also contribute to efficiency in state management by reducing the overhead associated with tracking and propagating state changes. This efficiency is evident in various use cases, from simple counters to complex data grids where the state is in constant flux.
With Signals, updates are propagated in a more direct and controlled manner. This streamlined update mechanism can lead to faster user interface interactions and a smoother overall user experience, as the browser has fewer tasks to manage, leaving more resources available for other critical operations.
Precision of Signal Updates
Signals empower developers to implement fine-tuned control over their application’s state updates. This precision is especially beneficial when dealing with nested components or when a specific UI part depends on certain state variables. By updating only the components that are truly dependent on the state changes, developers can ensure that their applications react in a predictable and controlled manner to state updates.
This precise update mechanism also leads to better state encapsulation and separation of concerns within the application. By using Signals, developers can structure their state logic more aligned with the application’s component hierarchy and user interaction patterns.
Optimization in Larger Applications
As applications scale, the number of components and the complexity of state management typically increase. In such scenarios, preventing unnecessary re-rendering cascades becomes crucial for maintaining performance. Signals excel in these environments by offering a targeted update mechanism that scales efficiently with the application’s size.
In large applications, the performance impact of each re-render can be amplified due to the interconnected nature of component trees. Signals help to mitigate this by ensuring that only the necessary parts of the application are updated. This optimization is not just about improving load times or responsiveness but also resource management. By minimizing the browser’s rendering engine workload, Signals helps reduce the application’s overall resource footprint, which can be a critical factor in mobile environments or devices with limited processing power.
Use Cases for Signals
Adopting Signals in React applications opens up a new realm of possibilities for handling various events and interactions more efficiently. This section delves into some common use cases where Signals can be particularly advantageous, from enhancing user interface interactions to facilitating seamless cross-component communication and managing system-level events.
User Interface Interactions
User interface interactions, such as button clicks and mouse movements, are fundamental to any interactive web application. These interactions often require quick and isolated updates to the UI without the need to re-render entire components. Signals are adept at handling such scenarios.
For example, consider a dynamic form where user inputs need to be validated in real time. Utilizing Signals to handle the form’s state can lead to instantaneous feedback, as each keystroke or selection doesn’t necessitate a full component refresh. The result is a responsive and fluid user experience, which is a cornerstone of modern UI design.
Cross-Component Communication
Cross-component communication is another area where Signals prove to be incredibly useful. In larger applications, passing states between components—especially those not directly related in the hierarchy—can become cumbersome and lead to prop drilling or the overuse of the global state.
Signals provide a more elegant solution. They allow different components or modules to subscribe to specific signals, creating a reactive data flow that is both decoupled and efficient. This approach simplifies the state management architecture and makes the data flow within the app more transparent and easier to follow.
Let’s consider a simple code example in which two sibling components need to share and react to the same state without passing props through their parent component.
Here’s a traditional React scenario without Signals:
// ParentComponent.js
import React, { useState } from "react";
import ComponentA from "./ComponentA";
import ComponentB from "./ComponentB";
function ParentComponent() {
const [sharedState, setSharedState] = useState("");
return (
<div>
<ComponentA sharedState={sharedState} setSharedState={setSharedState} />
<ComponentB sharedState={sharedState} setSharedState={setSharedState} />
</div>
);
}
In the example above, ComponentA
and ComponentB
need to share sharedState
. The state is lifted to their common parent, ParentComponent
, which passes it down as props. This is a simple pattern but can lead to prop drilling and makes the components tightly coupled to the parent’s structure.
Now, let’s reimagine this scenario using Signals:
// sharedSignal.js
import { signal } from "@preact/signals";
// This creates a shared signal that any component can import and use.
export const sharedStateSignal = signal("");
// ComponentA.js
import { sharedStateSignal } from "./sharedSignal";
function ComponentA() {
return (
<div>
<input
type="text"
value={sharedStateSignal.value}
onChange={(e) => (sharedStateSignal.value = e.target.value)}
/>
</div>
);
}
// ComponentB.js
import { sharedStateSignal } from "./sharedSignal";
function ComponentB() {
return <div>{sharedStateSignal.value}</div>;
}
In the Signal-enabled example, ComponentA
and ComponentB
import the same signal from sharedSignal.js
. When ComponentA
updates the signal’s value, ComponentB
automatically receives the updated value. There’s no need for prop drilling, and the components remain decoupled from the parent, leading to a cleaner and more maintainable codebase.
This code demonstrates the elegance and simplicity of using Signals for cross-component communication. It’s particularly powerful in larger applications where maintaining a clear and efficient data flow is crucial.
System-Level Events
Managing system-level events, such as data updates, network activity, or user sessions, is crucial for the application’s integrity. Signals offer a streamlined way to handle these events by updating the relevant parts of the application in response to changes.
For instance, a signal could be used to track a user’s online/offline status. Components like notification badges, chat boxes, or data sync indicators can subscribe to this signal and update accordingly, ensuring that the user is always presented with the system’s most current state.
Practical Implementation
In developing a practical understanding of how Signals can enhance state management in React applications, let’s address a common challenge faced in web development: managing state in a to-do list application with persistence and reactive updates.
The Challenge: State Management in To-Do Applications
To-do applications are quintessential examples of state-driven web apps. They are simple yet highlight several key aspects of state management, including adding, deleting, and toggling states of items and persisting these states across sessions. A typical to-do application might suffer from the following challenges:
- State Persistence: Ensuring the user’s to-dos are saved across browser sessions is necessary. Traditional state management would require explicit syncing with a storage mechanism, like local storage, often leading to redundant code.
- Reactive Updates: The UI must reflect these changes immediately as to-dos are added or modified. In larger applications, unnecessary re-renders can affect performance and lead to a sluggish user experience. Derived State: We often need to display derived states, such as a count of completed tasks or a filtered list of active to-dos. Calculating these on every render can be inefficient.
Solution: Signals to the Rescue
Signals address these challenges by providing a more efficient and streamlined way to handle states. Here’s how:
- State Persistence with Signals: Signals can integrate with local storage to persist without additional code to synchronize state changes. This means less boilerplate and a more straightforward way to ensure user data is saved.
- Efficient Reactive Updates: Components subscribe to state changes reactively with Signals. When a signal updates, only the components dependent on that state update, minimizing unnecessary re-renders and keeping the UI snappy.
- Computed Functions for Derived State: Signals can be combined with computed functions to create dynamic states that respond to changes in the signal values. This is efficient and declarative, making the codebase easier to maintain.
Demonstrating Signals with a To-do List
Let’s consider a to-do list application that uses Signals for state management, local storage for persistence, and computed functions for derived states.
- Creating a Signal for To-Dos: We start by creating a signal that holds our to-dos and a function to update this signal when to-dos are added or removed.
// signals/todoSignal.js
import { signal } from '@preact/signals';
export const todoListSignal = signal(getInitialTodos());
function getInitialTodos() {
// Retrieve to-dos from local storage or initialize with an empty array
return JSON.parse(localStorage.getItem('todoList')) || [];
}
- Syncing with Local Storage: We create a function that updates local storage whenever our to-dos change. This function is called whenever we modify the to-do signal.
// signals/todoSignal.js
// ...
todoListSignal.subscribe((currentTodos) => {
localStorage.setItem('todoList', JSON.stringify(currentTodos));
});
- Using Computed Functions for Derived State: We can set up computed signals for things like the count of completed to-dos or a filtered list of active to-dos, which will update automatically when the to-dos signal changes.
// signals/computedTodosSignal.js
import { computed } from '@preact/signals';
import { todoListSignal } from './todoSignal';
export const completedTodosCount = computed(() =>
todoListSignal.value.filter(todo => todo.completed).length
);
export const activeTodos = computed(() =>
todoListSignal.value.filter(todo => !todo.completed)
);
After incorporating key functionalities such as adding, deleting, and updating todo items to the above, the application’s user interface showcases the speed and efficiency of signals:
For those interested in exploring further, the complete source code is accessible here
The Practical Benefits
By employing Signals in this way, we’ve created a to-do list application that automatically saves the user’s data, provides instantaneous UI updates, and efficiently calculates and displays derived states. This results in a cleaner, more maintainable codebase and an application that is pleasant to use, scales well, and provides a seamless user experience.
This practical implementation demonstrates not just the ‘what’ and ‘how’ of using Signals but also the ‘why’ of solving specific, real-world problems in state management with elegance and efficiency.
Conclusion
In our exploration of Signals in React, we’ve seen their potential to streamline state management and enhance application performance. They offer a fine-grained update mechanism that minimizes re-renders, simplifies codebases, and scales efficiently with application complexity.
The integration of Signals marks a pivotal advancement in React development, promising improved responsiveness and user experience. As the community embraces this model, Signals may become a staple in the React ecosystem, leading to more performant and maintainable web applications.
Looking forward, the continued adoption and evolution of Signals stand to shape the future of state management, aligning with the ever-growing demands for dynamic and interactive web experiences.
To learn more about signals, check out the Documentation by Preact.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.