Состояние

Управление состоянием - важная часть любого приложения. В Qwik мы различаем два типа состояния - реактивное и статичное:

  1. Статичное состояние - это всё, что может быть сериализовано: строка, число, объект, массив... что угодно.
  2. Реактивное состояние, напротив, создаётся с помощью useSignal() или useStore().

Важно отметить, что состояние в Qwik - это не обязательно состояние компонента, а скорее состояние приложения, которое может быть инстанцировано любым компонентом.

useSignal()

Используйте useSignal() для создания реактивного сигнала (форма состояния). Функция useSignal() принимает начальное значение и возвращает реактивный сигнал.

Реактивный сигнал, возвращаемый функцией useSignal(), состоит из объекта с единственным свойством .value. Если вы измените свойство сигнала value, то любой компонент, который зависит от него, будет обновлён автоматически.

import { component$, useSignal } from '@builder.io/qwik';
 
export default component$(() => {
  const count = useSignal(0);
 
  return (
    <button onClick$={() => count.value++}>
      Прибавить {count.value}
    </button>
  );
});

Приведённый выше пример показывает, как может быть использован useSignal() в компоненте счётчика для отслеживания счёта. Изменение свойства count.value приведёт к автоматическому обновлению компонента. Например, когда свойство изменяется в обработчике нажатия кнопки, как в примере выше.

useStore()

Работает аналогично useSignal(), но принимает объект в качестве начального значения, а реактивность по умолчанию распространяется на вложенные объекты и массивы. Можно рассматривать состояние как сигнал с несколькими значениями, или объект, состоящий из нескольких сигналов.

Используйте хук useStore(initialStateObject) для создания реактивного объекта. Он принимает исходный объект (или фабричную функцию) и возвращает реактивный объект.

import { component$, useStore } from '@builder.io/qwik';
 
export default component$(() => {
  const state = useStore({ count: 0, name: 'Qwik' });
 
  return (
    <>
      <button onClick$={() => state.count++}>Прибавить</button>
      <p>Счётчик: {state.count}</p>
      <input
        value={state.name}
        onInput$={(_, el) => (state.name = el.value)}
      />
    </>
  );
});

ПРИМЕЧАНИЕ Чтобы реактивность работала так, как ожидается, убедитесь, что сохранили ссылку на реактивный объект, а не только на его свойства. Например, написание let { count } = useStore({ count: 0 }) и последующее изменение count не вызовет обновления компонентов, зависящих от этого свойства.

Поскольку useStore() отслеживает глубокую реактивность, это означает, что массивы и объекты внутри состояния также будут реактивными.

import { component$, useStore } from '@builder.io/qwik';
 
export default component$(() => {
  const store = useStore({
    nested: {
      fields: { are: 'также отслеживается' },
    },
    list: ['Item 1'],
  });
 
  return (
    <>
      <p>{store.nested.fields.are}</p>
      <button
        onClick$={() => {
          // Несмотря на то, что мы изменяем вложенный объект, это вызовет ререндер.
          store.nested.fields.are = 'отслеживается';
        }}
      >
        Нажатие на меня работает, потому что состояние отслеживается на всю глубину вложенности
      </button>
      <br />
      <button
        onClick$={() => {
          // Поскольку состояние отслеживается на всю глубину вложенности, это вызовет ререндер.
          store.list.push(`Item ${store.list.length}`);
        }}
      >
        Добавить в список
      </button>
      <ul>
        {store.list.map((item, key) => (
          <li key={key}>{item}</li>
        ))}
      </ul>
    </>
  );
});

Обратите внимание, что для того, чтобы useStore() отследил все вложенные свойства, ему нужно выделить много Proxy-объектов. И если у вас много вложенных свойств, это может стать проблемой производительности. В этом случае вы можете использовать deep: false-опцию для отслеживания только свойств верхнего уровня.

const shallowStore = useStore(
  {
    nested: {
      fields: { are: 'также отслеживается' }
    },
    list: ['Item 1'],
  },
  { deep: false }
);

Методы

Чтобы предоставить методы в состоянии, вы должны обернуть их в QRL и ссылаться на хранилище состояния с помощью this, как показано ниже:

import { component$, useStore, $, type QRL } from "@builder.io/qwik";
 
type CountStore = { count: number; increment: QRL<(this: CountStore) => void> };
 
export default component$(() => {
  const state = useStore<CountStore>({
    count: 0,
    increment: $(function (this: CountStore) {
      this.count++;
    }),
  });
 
  return (
    <>
      <button onClick$={() => state.increment()}>Прибавить</button>
      <p>Счётчик: {state.count}</p>
    </>
  );
});

Вычисленное состояние

В Qwik есть два способа создания вычисляемых свойств, каждый из которых имеет свой сценарий использования (в порядке предпочтения):

  1. useComputed$(): является предпочтительным способом создания вычисляемых свойств. Используйте его, когда вычисляемое свойство может быть получено синхронно исключительно из исходного состояния (текущего состояния приложения). Например, создание строки из строчных букв или объединение имени и фамилии в полное имя.

  2. useResource$(): используется, когда вычисляемое свойство является асинхронным или состояние приходит извне приложения. Например, получение текущей погоды (внешнее состояние) на основе текущего местоположения (внутреннее состояние приложения).

Помимо двух описанных выше способов создания вычисляемых свойств, существует также более низкоуровневый (useTask$()). Этот способ не производит новый сигнал, а скорее изменяет существующее состояние или производит побочный эффект.

useComputed$()

Использование useComputed$ позволяет мемоизировать значение, полученное синхронно из другого состояния.

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

import { component$, useComputed$, useSignal } from '@builder.io/qwik';
 
export default component$(() => {
  const name = useSignal('Qwik');
  const capitalizedName = useComputed$(() => {
    // будет автоматически повторно выполняться при изменении name.value
    return name.value.toUpperCase();
  });
 
  return (
    <>
      <input type="text" bind:value={name} />
      <p>Название: {name.value}</p>
      <p>Прописное название: {capitalizedName.value}</p>
    </>
  );
});

ПРИМЕЧАНИЕ Поскольку useComputed$() является синхронным, нет необходимости в явном отслеживании входных сигналов.

useResource$()

Используйте useResource$() для создания вычисляемого значения, которое выводится асинхронно. Это асинхронная версия useComputed$(), которая включает состояние ресурса (loading, resolved, rejected) помимо значения.

Распространённым случаем использования useResource$() является получение данных из внешнего API в самом компоненте, это означает, что выполнение может происходить как на сервере, так и на клиенте.

Хук useResource$ предназначен для использования с <Resource />. Компонент <Resource /> - это удобный способ отображения различных пользовательских интерфейсов в зависимости от состояния ресурса.

import {
  component$,
  Resource,
  useResource$,
  useSignal,
} from '@builder.io/qwik';
 
export default component$(() => {
  const prNumber = useSignal('3576');
 
  const prTitle = useResource$<string>(async ({ track }) => {
    // он будет запускаться сначала при монтировании (на сервере), а затем повторно при изменении prNumber (на клиенте)
    // это означает, что данный код будет выполняться на сервере и в браузере
    track(() => prNumber.value);
    const response = await fetch(
      `https://api.github.com/repos/BuilderIO/qwik/pulls/${prNumber.value}`
    );
    const data = await response.json();
    return data.title as string;
  });
 
  return (
    <>
      <input type="number" bind:value={prNumber} />
      <h1>PR#{prNumber}:</h1>
      <Resource
        value={prTitle}
        onPending={() => <p>Загрузка...</p>}
        onResolved={(title) => <h2>{title}</h2>}
      />
    </>
  );
});

Примечание: Важно понимать, что useResource$ выполняется при начальном рендере компонента (как и useTask$). Часто бывает желательно начать выборку данных на сервере в рамках первоначального HTTP-запроса до того, как компонент будет отображён. Получение данных как часть SSR является более распространённым и предпочтительным способом загрузки данных и выполняется через API routeLoader$. useResource$ - это скорее низкоуровневый API, который полезен, когда вы хотите получить данные в браузере.

Во многих отношениях useResource$ похож на useTask$. Основные различия заключаются в следующем:

  • useResource$ позволяет вам возвращать "значение";
  • useResource$ не блокирует рендер, пока ресурс находится в процессе загрузки.

См. routeLoader$ для ранней выборки данных в рамках начального HTTP-запроса.

ПРИМЕЧАНИЕ: во время SSR компонент <Resource> приостанавливает рендер до тех пор, пока ресурс не будет разрешён. Таким образом, SSR не будет отображать индикатор загрузки.

Расширенный пример

Более полный пример получения данных с помощью AbortController, track и cleanup. В этом примере будет получен список анекдотов на основе запроса, введённого пользователем, автоматически реагируя на изменения в запросе, включая прерывание запросов, которые в настоящее время находятся на рассмотрении.

import {
  component$,
  useResource$,
  Resource,
  useSignal,
} from '@builder.io/qwik';
 
export default component$(() => {
  const query = useSignal('busy');
  const jokes = useResource$<{ value: string }[]>(
    async ({ track, cleanup }) => {
      track(() => query.value);
      // Хорошей практикой является использование `AbortController` для прерывания получения данных, если
      // поступает новый запрос. Мы создаем новый `AbortController` и регистрируем `cleanup`-
      // функцию, которая вызывается при повторном запуске этой функции.
      const controller = new AbortController();
      cleanup(() => controller.abort());
 
      if (query.value.length < 3) {
        return [];
      }
 
      const url = new URL('https://api.chucknorris.io/jokes/search');
      url.searchParams.set('query', query.value);
 
      const resp = await fetch(url, { signal: controller.signal });
      const json = (await resp.json()) as { result: { value: string }[] };
 
      return json.result;
    }
  );
 
  return (
    <>
      <label>
        Запрос: <input bind:value={query} />
      </label>
      <button>поиск</button>
      <Resource
        value={jokes}
        onPending={() => <>Загрузка...</>}
        onResolved={(jokes) => (
          <ul>
            {jokes.map((joke, i) => (
              <li key={i}>{joke.value}</li>
            ))}
          </ul>
        )}
      />
    </>
  );
});

Как мы видим в приведённом выше примере, useResource$() возвращает объект ResourceReturn<T>, который работает как реактивный промис, содержащее данные и состояние ресурса.

Состояние resource.loading может быть одним из следующих:

  • false - данные пока отсутствуют;
  • true - данные доступны (промис либо разрешён, либо отклонён).

Обратный вызов, переданный в useResource$(), запускается сразу после завершения обратного вызова useTask$(). Более подробную информацию см. в разделе Lifecycle.

<Resource />

<Resource /> - это компонент, предназначенный для использования с useResource$(), который отображает различное содержимое в зависимости от статуса ресурса: ожидание, разрешён или отклонён.

<Resource
  value={weatherResource}
  onPending={() => <div>Загрузка...</div>}
  onRejected={() => <div>Не удалось загрузить погоду</div>}
  onResolved={(weather) => {
    return <div>Температура: {weather.temp}</div>;
  }}
/>

Стоит отметить, что <Resource /> не требуется при использовании useResource$(). Это просто удобный способ отображения состояния ресурса.

Этот пример показывает, как useResource$ используется для выполнения вызова к API agify.io. Он будет угадывать возраст человека по имени, введённому пользователем, и будет обновляться каждый раз, когда пользователь вводит имя.

import {
  component$,
  useSignal,
  useResource$,
  Resource,
} from '@builder.io/qwik';
 
export default component$(() => {
  const name = useSignal<string>();
 
  const ageResource = useResource$<{
    name: string;
    age: number;
    count: number;
  }>(async ({ track, cleanup }) => {
    track(() => name.value);
    const abortController = new AbortController();
    cleanup(() => abortController.abort('cleanup'));
    const res = await fetch(`https://api.agify.io?name=${name.value}`, {
      signal: abortController.signal,
    });
    return res.json();
  });
 
  return (
    <section>
      <div>
        <label>
          Введите свое имя, и я угадаю ваш возраст!
          <input onInput$={(ev, el) => (name.value = el.value)} />
        </label>
      </div>
      <Resource
        value={ageResource}
        onPending={() => <p>Загрузка...</p>}
        onRejected={() => <p>Не удалось получить данные</p>}
        onResolved={(ageGuess) => {
          return (
            <p>
              {name.value && (
                <>
                  {ageGuess.name} {ageGuess.age} лет
                </>
              )}
            </p>
          );
        }}
      />
    </section>
  );
});

Передача состояния

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

Существует два способа передачи состояния другим компонентам:

  1. передача состояния дочернему компоненту явным образом через параметр;
  2. передача состояния неявно, через контекст.

Использование параметров

Самый простой способ передачи состояния другим компонентам - это передача через параметры.

import { component$, useStore } from '@builder.io/qwik';
 
export default component$(() => {
  const userData = useStore({ count: 0 });
  return <Child userData={userData} />;
});
 
interface ChildProps {
  userData: { count: number };
}
export const Child = component$<ChildProps>(({ userData }) => {
  return (
    <>
      <button onClick$={() => userData.count++}>Прибавить</button>
      <p>Счётчик: {userData.count}</p>
    </>
  );
});

Использование контекста

API контекста - это способ передачи состояния компонентам без необходимости передавать его через параметры (позволяет избежать проблем с пробросом параметров). Все дочерние компоненты в дереве автоматически могут получить доступ к ссылке на состояние с возможностью чтения/записи.

Подробнее об этом можно прочитать в разделе Контекст.

import {
  component$,
  createContextId,
  useContext,
  useContextProvider,
  useStore,
} from '@builder.io/qwik';
 
// Объявление идентификатора контекста
export const CTX = createContextId<{ count: number }>('stuff');
 
export default component$(() => {
  const userData = useStore({ count: 0 });
 
  // Предоставляем состояние контексту по идентификатору контекста
  useContextProvider(CTX, userData);
 
  return <Child />;
});
 
export const Child = component$(() => {
  const userData = useContext(CTX);
  return (
    <>
      <button onClick$={() => userData.count++}>Прибавить</button>
      <p>Счётчик: {userData.count}</p>
    </>
  );
});

noSerialize()

Qwik гарантирует, что всё состояние приложения всегда сериализуемо. Это важно для того, чтобы приложения Qwik имели свойство возобновляемости.

Иногда необходимо хранить данные, которые не могут быть сериализованы, noSerialize() говорит Qwik даже не пытаться сериализовать помеченное значение.

Например, ссылка на стороннюю библиотеку, такую как Monaco editor, всегда будет нуждаться в noSerialize(), так как она не сериализуема.

Если значение помечено как несериализуемое, то это значение не подвергается событиям сериализации, таким как возобновление работы приложения на клиенте с сервера SSR. В такой ситуации значение будет установлено в undefined, и разработчик должен повторно инициализировать значение на клиенте.

import {
  component$,
  useStore,
  useSignal,
  noSerialize,
  useVisibleTask$,
  type NoSerialize,
} from '@builder.io/qwik';
import type Monaco from './monaco';
import { monacoEditor } from './monaco';
 
export default component$(() => {
  const editorRef = useSignal<HTMLElement>();
  const store = useStore<{ monacoInstance: NoSerialize<Monaco> }>({
    monacoInstance: undefined,
  });
 
  useVisibleTask$(() => {
    const editor = monacoEditor.create(editorRef.value!, {
      value: 'Hello, world!',
    });
    // Monaco не является сериализуемым, поэтому мы не можем сериализовать его как часть SSR.
    // Однако мы можем инстанцировать его на клиенте после того, как компонент станет видимым.
    store.monacoInstance = noSerialize(editor);
  });
  return <div ref={editorRef}>загрузка...</div>;
});

Участники

Спасибо всем участникам, которые помогли сделать эту документацию лучше!

  • nnelgxorz
  • the-r3aper7
  • voluntadpear
  • kawamataryo
  • JaymanW
  • RATIU5
  • manucorporat
  • literalpie
  • fum4
  • cunzaizhuyi
  • zanettin
  • ChristianAnagnostou
  • shairez
  • forresst
  • almilo
  • Craiqser
  • XiaoChengyin
  • gkatsanos
  • adamdbradley
  • mhevery
  • wtlin1228
  • AnthonyPAlicea
  • sreeisalso
  • wmertens
  • nicvazquez
  • mrhoodz
  • eecopa
  • fabian-hiller
  • julianobrasil
  • aivarsliepa
  • Balastrong