Universal Soldier: A Review of the Signals Library by The Preact Team

State managers have long been a kind of meme among software developers. There is a popular opinion that, rather than focusing on addressing really important and urgent issues, frontend developers do nothing but constantly rewrite projects from one state manager to another. Luckily, the number of projects and the flow of new ones being released to Open Source mean that they can get away with that.

inDrive.Tech
Geek Culture

--

My name is Zhenya, and I still work as a frontend developer on the Quick Experiments Team at inDrive. Since I don’t like to stand out from the crowd either, I suggest you check out Signals, a new solution from the Preact team. In the lead-up article, the creators of the library assert that these days, there is a huge number of app state management solutions out there, but they require complex and time-consuming integration with the framework. This complicates the design process, as the special requirements of a particular state manager have to be kept in mind at all times. App development becomes more complicated as well, because a lot of time and effort has to be put into integrating the state manager and the rendering library.

There is a solution, though! Signals. According to its creators, this solution combines optimal performance for app developers and easy implementation in the framework. Below the cut is a detailed review of the library.

I will skip going over all the library’s methods again, from the documentation (incidentally, there are four of them in total), and instead I’ll try to describe the issues that can be resolved by using the new library. To do this, we need to analyze and understand the problems with React’s existing ways of storing and transferring data, which are roughly divided by the authors of the library into two types:

1. Local State (useState, useReducer for complex logic workflows) followed by data transfer via props.

2. Data transfer via context (useContext).

Local State

All React applications use the useState hook. It’s effective enough to take care of things in projects with uncomplicated logic and a small code base. But situations like that are relatively rare. If you come across a situation where multiple components need access to the same state fragment — that state is lifted up to a common ancestor component. As the number of components increases, this process is repeated many times over. As a result, this usually causes a prop-drilling problem, along with unnecessary re-rendering of multiple components or the entire tree (if most of the state is at the root of the tree). To remove the rendering, you can use memoization, but there are two points to note here:

1. Memoization only works with pure functional components, i.e., without any side effects.

2. Memoization is more useful for functions that require extensive calculations. In other cases, performance optimization will be insignificant at best, and at worst, it will lead to errors.

Accordingly, with a large code base, it’s quite a challenge to determine where to do the optimizing (there are no clear rules on how to use it). And in some cases, memoization will have the opposite effect on performance.

Instead of memoization, you can use another strategy, namely, lifting up a component and then passing it in as a prop, as described in detail in this article. This technique is fairly simple, but the results of its implementation can be quite impressive. This approach is not an option, however, when the same data has to be accessible by multiple components in the tree, and at different nesting levels.

Conclusion: Local State is difficult to use when multiple components require access to the same state fragment. “Prop-drilling” and unnecessary rendering are the side effects you have to deal with, while memoization is not a panacea for performance issues.

Context

After realizing that Local State is not an option that can serve as your one-stop solution, users begin to contemplate implementing Contexts. Context is not a state management tool, but rather a DI mechanism; it doesn’t perform any “control” functions on its own.

Context is useful when data has to be accessible by components at different levels of nesting. This gets rid of prop-drilling, making it easier to track data transfers between different modules. One might think this would take care of the problem in question, but it’s not as simple as that.

When the provider gets a new value, all the components underneath it are updated and must render themselves. Even the ones that are just functional components, which only “care” about part of the data. This may lead to potential performance problems.

To work around this issue, Context should be kept as close as possible to where it’s needed, whereas the data is logically separated and stored in different state objects. With this approach in place, there will be several providers.

The problem with using Context, as the authors of Signals emphasize, is that as the size of the code base increases, so too will the number of components that are only needed in order to exchange data. The business logic inevitably becomes dependent on several contexts, and this means that the developer will have to implement the component at a specific place in the tree.

Adding a context consumer in the middle of the tree is bad for performance, because it increases the number of components that are re-rendered when the context changes. Only memoization can fix this issue. It has its limitations, though, as was shown in the section on Local State.

Conclusion: there are a multitude of nuances to take into consideration when using Context. The basic advice is that Context is only the best fit if the data, such as theme or localization, is rarely changed, or when prop-drilling is really becoming an issue.

Signals

As you can see, both ways of storing and transferring data require different techniques to improve performance. They are intrinsically universal in their applications, and, ideally, the perfect solution would be fast by default and have an easy-to-use API. Now we can move on to the library that is supposed to address these concerns, namely, Signals.

The authors write that the library is unique in that state changes automatically update components and the user interface in the most efficient way possible. Developers don’t need to write code for performance optimization tasks as the system is fast by default, and does not require memoization or tricks throughout your app. Signals provide the benefits of fine-grained state updates, regardless of whether that state is global, passed via props or context, or local to a component.

Under the hood, it works like this: a (signal) object containing a value property with a certain value is passed through the component tree, instead of passing a value. Since the components see the signal and not its value, signals can be updated, without re-rendering components to jump over immediately to the specific components in the tree, which actually access the signal’s value.

The creators of the library take advantage of the fact that the app state tree is usually much smaller than the component tree. This speeds up the rendering process because it takes much less work to update the state tree. The screenshot below shows the trace for the same app measured twice — once using hooks and the second time using Signals:

The library is highly versatile and can be used for a wide range of tasks. Unlike hooks, it can be used both inside and outside of components. Signals also work great alongside both hooks and class components.

Another big advantage offered by the library, which the standard hooks definitely do not have, is that it works not only with Preact, but also with React, Svelte, and many other solutions. What is particularly amazing is that the preact/signals “Core Root” package is designed to work outside of Preact as well!

Now let’s compare preact/signals-react with other state management libraries in terms of their weight:

  • preact/signals-core is the root library: 1.4kB, no external dependencies.
  • preact/signals — Hooks for working with Preact: 2.4kB, under the hood: preact/signals-core, no other dependencies.
  • preact/signals-react — Hooks for working with React: 2.4kB, under the hood: preact/signals-core and one external dependency as a hook from use-sync-external-store (de facto its library implementation of the same name from React Version 18).

It’s not hard to see that preact/signals-react stands out, in a qualitative sense, from the other libraries presented, and is only surpassed by nanostores — and even then, only slightly.

Interestingly, preact/signals-react works on the basis of overriding JSX inside of React, essentially embedding itself there. By the library developers’ own admission, this is a “crutch” version of the code, but it works, and this means that components require no modifications or wrappers to support automatic subscriptions.

React uses the useSyncExternalStore hook mentioned above, subscribes to the store, and gets a snapshot of the current “version.” Whenever the “version” is upgraded, the library alerts React that it’s time to update the component.

So far, my experience of using this library has been fairly limited. However, the fact that Signals can be used outside of Preact, that it has Lazy by Default and Optimal Updates, and that it doesn’t have any dependencies (as in Hooks), holds much promise for its use in a variety of scenarios. Even some that have nothing to do with rendering user interfaces.

For example, using Signals together with post Message allows you to synchronize different browser tabs or frames with each other. So, if you need reactivity in the most difficult and complicated cases, I recommend that you take a closer look at Signals. Hopefully, this solution will help you avoid a lot of wasted time and stress.

Sources:

  1. https://preactjs.com/blog/introducing-signals/
  2. https://kentcdodds.com/blog/
  3. https://twitter.com/_developit
  4. https://reactjs.org/
  5. https://stackoverflow.com/
  6. https://blog.isquaredsoftware.com/

--

--