RellikaZZ

RellikaZZ

Introduction and Principles of Frontend Signal

WHY#

Before explaining what a signal is, let's look at a case.

// Using useEffect with a timer to implement increment, what is wrong with the Demo below? Why is it like this, and what should be changed?
function Demo() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setInterval(() => {
      setCount(count + 1);
    }, 500);
  }, []);

  useEffect(() => {
    setInterval(() => {
      console.log(count);
    }, 500);
  }, []);

  return <div>{count}</div>;
}

export default Demo;

This code is expected to print an incrementing count every 500ms. Ignoring the issue of not clearing the timer, this code will actually only print 0 continuously. This is because the dependency array of useEffect does not include count, so count is always 0. If you have eslint-plugin-react-hooks installed along with react-hooks/exhaustive-deps, you can see the warning more clearly.

Common solutions include the following three:

  1. Saving state through ref, which is a commonly used solution.
import * as React from 'react';

export default function App() {
  const countRef = React.useRef(0);
  const [count, setCount] = React.useState(0);
  React.useEffect(() => {
    setInterval(() => {
      countRef.current = countRef.current + 1;
      // To ensure the page responds to changes, setCount is needed.
      setCount(countRef.current);
    }, 500);
  }, []);

  return (
    <div>
      <h1>{count}</h1>
    </div>
  );
}

This utilizes the principle of ref being unchanged.

  1. Using useReducer.
import * as React from 'react';

const initialCount = 0;
const reducer = (count, action) => {
  switch (action.type) {
    case 'increment':
      return count + 1;
    default:
      throw new Error();
  }
};

export default function App() {
  const [count, dispatch] = React.useReducer(reducer, initialCount);
  React.useEffect(() => {
    setInterval(() => {
      dispatch({
        type: 'increment',
      });
    }, 500);
  }, [dispatch]);

  return (
    <div>
      <h1>{count}</h1>
    </div>
  );
}

This utilizes the principle of dispatch being unchanged.

  1. Saving the timer's callback to ref.
import * as React from 'react';

export default function App() {
  const savedCallback = React.useRef<() => void>();
  const [count, setCount] = React.useState(0);
  const callback = () => {
    setCount(count + 1);
  };
  React.useEffect(() => {
    savedCallback.current = callback;
  });
  React.useEffect(() => {
    setInterval(() => {
      savedCallback.current();
    }, 500);
  }, []);

  return (
    <div>
      <h1>{count}</h1>
    </div>
  );
}

This is slightly more concise but still introduces an additional ref. It can be seen that the above solutions all ensure compliance with react-hooks/exhaustive-deps, meaning that variables not present in deps cannot be used in useEffect. (As a side note: whether this rule should be strictly followed has been a topic of controversy in the past.) This is a common practice in React. What I want to introduce this time is another possibility: signal.

import * as React from 'react';
import { useSignal, useSignalEffect } from '@preact/signals-react';

export default function App() {
  const count = useSignal(0);
  useSignalEffect(() => {
    setInterval(() => {
      count.value++;
    }, 1000);
  });

  return (
    <div>
      <h1>{count}</h1>
    </div>
  );
}

What#

7980d6da-cbdc-4521-ad12-106b7af7c72e.png
Signals used in Qwik manage state, and their author (who is also the author of Angular) states that signals are the future of frontend frameworks. Vue, Solid.js, Preact, and Svelte have all implemented signals. The popularity of signals mainly addresses two issues:

  • Better DX (developer experience), meaning no need to manually maintain effect dependencies and no need to worry about closure traps.
  • Better fine-grained update performance.
    Better DX can be felt in the examples above, but where does better fine-grained update performance come from?

Fine-Grained Updates#

In the previous example, if we add some code to the non-signal implementation version, as follows:

// RefRenderDemo.tsx
import * as React from 'react';

function Child() {
  console.log('render child');
  return <div>child</div>;
}

export default function App() {
  const countRef = React.useRef(0);
  const [count, setCount] = React.useState(0);
  React.useEffect(() => {
    setInterval(() => {
      countRef.current = countRef.current + 1;
      setCount(countRef.current);
    }, 500);
  }, []);

  console.log('rendering');

  return (
    <div>
      <h1>{count}</h1>
      <Child />
    </div>
  );
}

In the console, you can see that every time count updates, it prints a line "rendering," and even the Child component will print "render child." This is because

useState() in React only returns the state value, which means it does not know how this state value is being used, and the entire component tree must be re-rendered in response to state changes.

If we switch to signals, "rendering" and "render child" will only be printed once.

// SignalRenderDemo.tsx
import * as React from 'react';
import { useSignal, useSignalEffect } from '@preact/signals-react';

function Child() {
  console.log('render child');
  return <div>child</div>;
}

export default function App() {
  const count = useSignal(0);
  useSignalEffect(() => {
    setInterval(() => {
      count.value++;
    }, 1000);
  });
  console.log('rendering');

  return (
    <div>
      <h1>{count}</h1>
      <Child />
    </div>
  );
}

The key difference is that signals return getters and setters, while useState returns values and setters.
Due to the syntactic sugar, the above demo may not be very clear; let's look at the signal implementation in Solid.js.

import { render } from "solid-js/web";
import { createSignal } from "solid-js";

function Counter() {
  const [count, setCount] = createSignal(0);
  setInterval(() => setCount(count() + 1), 1000);
  return <div>Count: {count()}</div>;
}

Count is actually a getter method, and the state value can only be obtained after calling it.

Principle#

When using signals, when the state value of a signal changes, the signal itself remains unchanged, which avoids component re-rendering. So how does it notify the component to update? In fact, it is implemented through a publish-subscribe pattern.

Signals are event emitters that hold a list of subscriptions. They notify their subscribers whenever their value changes.

If you are familiar with the reactive principles of Vue, you should find it relatively easy to understand signals. Below is a simplified implementation of createSignal and useSignal.

// mySignal.ts
import React from 'react';

let currentListener = undefined;

export const createSignal = (initialValue) => {
  // Subscription list
  const subscribers = new Set();
  // State value
  let value = initialValue;
  const getter = () => {
    if (currentListener !== undefined) {
      // When reading the value, collect subscriptions
      subscribers.add(currentListener);
    }
    return value;
  };
  const setter = (newValue) => {
    value = newValue;
    subscribers.forEach((subscriber) => {
      // When changing the value, execute subscriptions
      subscriber();
    });
  };
  return [getter, setter];
};

export const createSignalEffect = (callback) => {
  // Save the callback to currentListener
  currentListener = callback;
  // If the callback calls the signal's getter, currentListener will be stored in subscribers
  callback();
  // Restore currentListener
  currentListener = undefined;
};

//  Encapsulated as a hook
export const useSignal = (value) =>
  React.useMemo(() => createSignal(value), []);

Usage:

// MySignalDemo.tsx
import * as React from 'react';
import { useSignal, createSignalEffect } from './mySignal';

export default function App() {
  const [count, setCount] = useSignal(0);
  const nodeRef = React.useRef();
  // Simulate the subscription behavior of preact/signals during component rendering
  React.useEffect(() => {
    createSignalEffect(() => {
      nodeRef.current.innerText = count();
    });
  }, []);

  return <div ref={nodeRef} onClick={() => setCount(count() + 1)} />;
}

The part labeled "Simulate the subscription behavior of preact/signals during component rendering" refers to the logic mentioned in the source code comments.

image

Current Situation#

In my personal opinion, signals have many advantages, but currently, it is best not to use them in React projects. First, the React team does not consider signals to be a good pattern; you can refer to Dan's views on signals.
https://dev.to/this-is-learning/react-vs-signals-10-years-later-3k71 and

image

Secondly, in terms of implementation, @preact/signals-react uses a property __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED
https://github.com/preactjs/signals/blob/main/packages/react/src/index.ts#L22
This property translates to: internal secret property, do not use randomly! Otherwise, you will be fired! If you're interested in this property, you can read this article.
https://zhuanlan.zhihu.com/p/540482955

Summary#

Signals provide better DX and performance, and the principle is publish-subscribe, but personally, I feel that they are not very suitable for application in React at this time.

Demo Address#

https://stackblitz.com/edit/react-ts-k36uan?file=App.tsx

References#

https://overreacted.io/zh-hans/making-setinterval-declarative-with-react-hooks/
https://dev.to/this-is-learning/react-vs-signals-10-years-later-3k71
https://preactjs.com/blog/introducing-signals/
https://www.builder.io/blog/usesignal-is-the-future-of-web-frameworks#code-use-ref-code-does-not-render

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.