Understanding signals based state management in React 📡

In this post, I would like to write about the benefits signals based state management library bring on the table to improve React applications. We are going to do tradeoffs analysis to understand pros and cons of this approach and sharing my thoughts on why it is not popular in React ecosystem. Stay tuned and read on guys 😎

Previously, I wrote about how to build your own signals from scratch using vanilla JavaScript. As of writing, React is the most popular UI view library that does not come with signals based state management out of the box in contrast to Vue, Angular, Solid, Svelte, et cetera. The native solutions such as useState and useReducer are baked deeply into VDOM rendering mechanism of React. Whenever we trigger state update via these API, it will trigger the reconciliation process in React started from the VDOM node contains those hooks recursively all the ways to leaf nodes

React reconciliation process

Source - React official doc

Interestingly, React offers a special hook for external state to integrate with reconciliation process so-called useSyncExternalStore. Let's continue our previous journey on vanilla signals library and build a small integration with React and see how it goes

import { useEffect, useMemo, useReducer, useRef } from 'react'
import { signal, startSubscription } from './signals'

export function useSignal<T>(
  initialValue: T
): [() => T, (nextValue: T) => void] {
  return useMemo(() => signal(initialValue), []) as [
    () => T,
    (nextValue: T) => void,
  ]
}

export const useSignals = () => {
  const rerender = useReducer((x) => x + 1, 0)[1]
  const endSubscriptionRef = useRef<ReturnType<typeof startSubscription>>()

  if (!endSubscriptionRef.current) {
    endSubscriptionRef.current = startSubscription(rerender)
  }

  useEffect(() => endSubscriptionRef.current!(), [])
}

And here is how we use it:

import { useSignal, useSignals } from './signalsReact'

function ExpensiveComponent() {
  console.log('rendering ExpensiveComponent')
  // Block 1s
  const start = Date.now()
  while (Date.now() - start < 1000) {}
  return <p>ExpensiveComponent</p>
}

function App() {
  const [getCount, setCount] = useSignal(0)
  return (
    <>
      <h1>React app ⚛️</h1>
      <ValueDisplay getValue={getCount} />
      <ValueIncrementButton onClick={() => setCount(getCount() + 1)} />

      <ExpensiveComponent />
    </>
  )
}

function ValueDisplay({ getValue }) {
  useSignals()
  return <p>Value: {getValue()}</p>
}

function ValueIncrementButton({ onClick }) {
  return <button onClick={onClick}>Increment</button>
}

We build a custom hook so-called useSignal and return value getter / setter back. Imagine it's built with useState hook instead, every time we click on an increment button, it will re-render a costly ExpensiveComponent which takes 1 seconds to render even thought there is nothing worth re-rendering here. We would normally would do in React is leverage useMemo or memo API to add some memoization for this component so React will only run through it again when dependency list changes and do comparison using Object.is API. This is fine yet comes with a big commitment of everyone works directly with the codebase remember to memoize dependencies too or we can run into this funny issue. And everything will go in vain if someone decide to useContext powered by useState and / or useReducer behind the scene. It's becoming extremely hard to stay performant at scale from my personal observation in the industry. This is where people start reaching out for countless amount of state management out there such as Redux, Zustand, XState, Preact/signals-react, Jotai, MobX et cetera. Signals based state management libraries offer tempting solutions to React's ecosystem by trigger much fewer re-rendering compared to native solution. In the example above, if we click on the increment button, the app will respond responsively because it doesn't actually re-render ExpensiveComponent component. We already know from naked eyes that only ValueDisplay component needs to be re-rendered whenever count value changes. So, we mark it with useSignals to inform our custom signal library that it's about time to start subscription. Once component finishes accessing values for rendering and return, it's time to finish subscription via useEffect hook. As a result, this ValueDisplay will be the only component marked ready to re-render however values get changed in the whole React's tree - there is not manual optimization, no memoization, carefully review code when it comes to useMemo and memo usage, et cetera. We should aim for consuming state's value where it's needed so that our app can stay fast regardless of how we trigger updates - we can skip a bunch of costly computation caused by re-rendering middle components from native React solution.

However, there is nothing comes for free and everything is a tradeoffs in life. Let's talk about the cons of those libraries. First and foremost, it's obviously not official solution from React's core team, things might be broken without any guarantee. React recently came out with their own compiler to remove tediously manual optimization for hooks like useCallback and useMemo. I tried it with babel plugin from Preact and 💥 - it breaks

react({
  babel: {
    plugins: [
      ['module:@preact/signals-react-transform'],
      // This plugin will break the above transform if signals are store outside of the component
      ['babel-plugin-react-compiler', ReactCompilerConfig],
    ],
  },
})

For the last couple of years, React team has heavily focused on server side rendering of the library aka Server components. If your application are heavily client-side rendered which relies on one of those libraries. You already put your faith in the shoulders of authors that it will work seamlessly when you decide to go server side to leverage all advance features of React. Let's take Redux for example, it's once popular and now it is not. Dominant framework like Next.js had to work hard to integrate Redux on the server and hydrated everything back once it reaches client side. There is nothing marked in stone that it's React's responsibility, same for CSS-IN-JS libraries, so the future is not assured. The only things still stand over time are native React's API given how critical it is for Meta / Facebook to support their 100K plus components in production 🎉

Building software that can last at scale is really hard especially in FE world where everything does not seem to settle down and still evolving. Yet, I believe having a clear understanding of how things work can give us a full control of our applications and winning customers. It is not uncommon to go with biased feeling (I did 😭) and regretted on the past decisions we have made. As long as we understand what we ship to run on the web platform, there is nothing we need to fear about. It's long live JavaScript, HTML and CSS 🎉

There you have it guys, I hope this article is useful and explain well how signal based state management library edge out native React solutions in term of performance at the cost of extra abstraction. By aiming to have just enough abstractions for your applications is tough since there are shiny solutions everywhere, yet it will pay off in the long run when it comes to maintainability and scalability. Together, we make the world a better place through quality software 💞

❤️❤️❤️ Be well, Be happy ❤️❤️❤️