Как стать автором
Обновить

signals в качестве альтернативы useState в React

Уровень сложностиПростой
Время на прочтение6 мин
Количество просмотров2.5K

Привет, хабр! Больше года назад я впервые узнал про сигналы, а три месяца назад @Sin9k записал видео на эту тему. И поскольку сигналы по-прежнему обходят стороной, попробую немного исправить ситуацию)

В материале будет использоваться обёртка signals-react, так как изначально рассматриваемая библиотека написана под Preact.

Проблема

Как мы знаем, по умолчанию, если в компоненте поменялось локальное состояние, то и сам компонент, и все его потомки будут перерисованы. Что повлечёт за собой множество вычислений, которые в общем-то не нужны. Для примера я намеренно создам такую структуру, которая создаёт много проблем с перерисовками.

function App() {
  const [value, setValue] = useState('');

  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value);
  };

  const handleClick = () => {
    console.log(value);
  };

  return (
    <div className="app">
      <Input onChange={handleChange} />
      <Button onClick={handleClick} />
      <span>{value}</span>
    </div>
  );
}

Ничего сверхъестественного, есть родитель App с локальным состоянием value. Оно меняется через компонент Input, в который прокинут обоработчик handleChange, а также выводится в консоль при клике на Button. И до кучи дублируется текстом снизу) Сам Input пусть будет неконтролируемым, это сейчас не так важно.

Дочерние компоненты устроены максимально просто

// Input
type TProps = {
  onChange: ChangeEventHandler<HTMLInputElement>;
};

export const Input = ({ onChange }: TProps) => {
  return <input onChange={onChange}></input>;
};
// Button
type TProps = {
  onClick: MouseEventHandler<HTMLButtonElement>;
};

export const Button = ({ onClick }: TProps) => {
  return <button onClick={onClick}>Click me!</button>;
};

Если оставить всё как есть, то вполне ожидаемо и App, и его дочерние компоненты будут перерисовываться на ввод каждого символа в Input. В этом можно убедиться, если добавить выводы в консоль внутри компонентов. Вот что происходит, пока печатается слово "хабр"

И это без учёта первоначального рендера, StrictMode выпилен. React из коробки предлагает несколько хуков и ХОК, которые справятся с лишними рендерами. Для начала обернём хендлеры в useCallback

const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value);
  }, []);

const handleClick = useCallback(() => {
  console.log(value);
}, [value]);
// если не добавить value в массив зависимостей, то мы не сможем
// получить актуальное значение

И обернём дочерние компоненты в memo

const Button = ({ onClick }: TProps) => {
  console.log("button's rerender");
  return <button onClick={onClick}>Click me!</button>;
};

export const ButtonWithMemo = memo(Button);

С компонентом Input по аналогии) И даже этого не достаточно, ведь если вводить всё то же слово "хабр", то App, и Button снова перерисуются по 4 раза. В случае с Button это происходит потому что handleClick на каждый рендер App будет иметь новую ссылку, так как меняется value из массива зависимостей. Казалось бы, и так нагородили обёрток, но и это помогло не до конца. Чтобы исправить такое поведение, можно дублировань значение value в мутабельный ref, и уже оттуда читать всегда актуальное значение, без добавления value в массив зависимостей handleClick.

function App() {
  const [value, setValue] = useState('');
  const ref = useRef<null | string>(null);

  const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
    ref.current = e.target.value;
    setValue(e.target.value);
  }, []);

  const handleClick = useCallback(() => {
    console.log(ref.current);
  }, []);
...
}

И будто бы слишком много приходится делать и держать в голове ради простейшего функционала, который будет работать, как задумано. Окончательный пример с использованием средств React можно потыкать здесь

Решение

Вернёмся к тому виду, когда не было useCallback и memo. Для начала накатим signals командой

npm i @preact/signals-react

Дальше в App сделаем следующее

import { useSignal } from '@preact/signals-react';
import { useSignals } from '@preact/signals-react/runtime';

function App() {
  useSignals();
  // использую data вместо value, потому что у сигналов внутри лежит
  // свой ключ value, а value.value выглядит уже так себе
  const data = useSignal('');

  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    data.value = e.target.value;
  };

  const handleClick = () => {
    console.log(data.value);
  };

  console.log("parent's rerender");

  return (
    <div className="app">
    // обращаю внимание, компоненты без memo
      <Input onChange={handleChange} />
      <Button onClick={handleClick} />
      <span>{data}</span>
    </div>
  );
}

И на этом можно было бы расходиться, потому что всё - новых рендеров нет. Это не шутка, можете проверить сами. Но стоит как минимум объяснить, что же теперь здесь происходит)

Хук useSignals научил компонент App реагировать на сигналы. В теории это даёт нам возможность точечно указывать, какие компоненты должны реагировать на сигналы, а какие нет. Например ни Input(мы его не контролируем), ни Button до значения сигнала дела нет. На практике подобное может вылиться в useSignals в почти каждом компоненте и чтобы везде не писать одно и то же, разработчики предлагают использовать плагин signals-react-transform.

Хук useSignal создал локальный сигнал на уровне App, прямо как локальный стейт. Сигнал при этом мутабельный и его изменение не триггерит перерисовку компонента, в котором он находится. Здесь есть нюанс, об котором позже)

Из мутабельности сигнала следует, что handleChange теперь может напрямую мутировать значение сигнала через data.value, в работе handleClick видимых изменений не произошло. Глобально же разница в том, что теперь оба хендлера не создаются заново, так как App не перерисовывается, а следовательно и Input с Button тоже не перерисовываются. Однако Button теперь всегда имеет доступ к актуальному значению сигнала, и в App тоже отображается актуальное значение.

Вот тут начинается самое интересное, ведь в App, прямо в jsx, было прокинуто не значение сигнала, а сам сигнал. Есть два важных момента:

  • Если внутри сигнала лежит примитив, который нужно показать как есть, то действительно достаточно прокинуть сам сигнал в jsx - перерисуется только конкретная нода, сам компонент перерисован не будет;

  • Если внутри компонента, именно в jsx, использовать signal.value, то перерисуется весь компонент.

То есть если в App я сделаю так

...
return (
    <div className="app">
      ...
      <span>{data.value}</span>
    </div>
  );

То мы вернёмся к тому, с чего начали - к перерисовке всего и вся на каждый ввод символа в инпут. Не надо так) React с его приколами никуда не делся, и за этим нужно следить, ведь дочерние компоненты у нас снова без memo. Чтобы лучше продемонстрировать работу с сигналами в качестве пропсов, создадим отдельный компонент View, который будет представлять из себя ровно тот же span

import { Signal } from '@preact/signals-react';
import { useSignals } from '@preact/signals-react/runtime';

type TProps = {
  data: Signal<string>;
};

export const View = ({ data }: TProps) => {
  useSignals();
  console.log("view's rerender");
  return <span>{data}</span>;
};
// App

function App() {
  ...
  return (
    <div className="app">
      ...
      <View data={data} />
    </div>
  );
}

Теперь снова нет лишних рендеров, более того - сам компонент View тоже не перисовывается, выводов в консоль при наборе символов не будет, несмотря на использование useSignals. Здесь у кого-то встанет справедливый вопрос по поводу реакции на изменение сигнала, аналогичной useEffect. И такая реакция конечно же есть) Самое простое - использовать data.value вместо data в jsx компонента View. Тогда весь компонент перерисуется и мы сможем отреагировать на перерисовку базовым useEffect. Если же использовать более надёжные решения, которые предлагает библиотека, то есть хук useSignalEffect, который позволит реагировать на изменения сигнала без перерисовки компонента

export const View = ({ data }: TProps) => {
  useSignals();
  console.log("view's rerender");
  useSignalEffect(() => {
    console.log(data.value);
  });
  return <span>{data}</span>;
};
Консоль выглядит так
Консоль выглядит так

Применение

Так как рассматриваемый выше пример по большей части высосан из пальца, есть смысл предложить способы реального использования сигналов. В моём случае знакомство с ними началось с модалок - в React обращение с модалками обычно выглядит как-то так

// компонент, внутри которого триггерится показ модалки

const [isModalOpen, setIsModalOpen] = useState(false);
...
//где-то ниже по коду
setIsModalOpen(true)
...
// чаще всего в конце компонента
<>
  ...
  // какие-то компоненты или jsx
  <Modal isOpen={isModalOpen}>
</>

Ничего нового, поменяли состояние isModalOpen - нам всего-то нужно показать модалку поверх, а произошла перерисовка всего компонента, со всеми вытекающими. Чего можно избежать, если использовать для состояния открытия модального окна сигнал. В общем-то все те случаи, когда приходилось хранить значения в рефах, чтобы избежать лишних рендеров(пересозданий коллбэков), с сигналами можно забыть как страшный сон.

Само собой сигналы можно и нужно использовать не только для локального состояния - как внешние экспортируемые микросторы они тоже прекрасно себя покажут. Но если глобальных сторов у нас и так вагон, то более производительного аналога локального стора на уровне компонента вроде бы и нет. Сигналы же могут справляться с обеими функциями)

Также в документации указаны прочие полезные хуки и способы обращения с сигналами, поэтому пожалуйста, прежде чем писать "а как вот это сделать" или "вот тут меня сломалось" - загляните в доку) Финальный вариант с компонентом View лежит здесь

Теги:
Хабы:
+7
Комментарии6

Публикации

Истории

Работа

Ближайшие события

4 – 5 апреля
Геймтон «DatsCity»
Онлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань
20 – 22 июня
Летняя айти-тусовка Summer Merge
Ульяновская область