effector

Cover image for Компонент поиска на React
Sergey Sova
Sergey Sova

Posted on • Updated on

Компонент поиска на React

Задача очень простая на первый взгляд:
собрать компонент поиска по сайту, который будет выполнять поиск в соответствии с тем, как пользователь вводит запрос.

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

В javascript экосистеме подобная задача решается двумя техниками debounce и throttle, разницу и реализацию можно посмотреть по ссылке. Но если вкратце, то debounce отправляет запрос, когда пользователь полностью закончил ввод, а throttle будет отправлять раз в какой-то интервал, пока пользователь еще вводит, иногда такое поведение полезно.

Рассмотрим типичный компонент на React:

function Search() {
  return <input value="" onChange={fn} />;
}

function SearchResults() {
  const list = [];
  return (
    <div>
      {list.map(result => (
        <div key={result.id}>{result.text}</div>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Здесь нет фактической реализации debounce и выполнения запроса на сервер. Я оставлю ссылку здесь на статью с примером реализации.

Проектирование подобного компонента стоит начать с простой техники: Состояния, События, Сайд-эффекты. Именно в таком порядке определяем необходимые для данной бизнес-логики сущности, которые будем описывать на языке effector. Конечно, в процессе нам могут понадобится дополнительные юниты и мы и опишем. Прямо сейчас мы работаем с целевым API нашей модели.

Состояния

Какие состояния нужны для определения нашего поиска? Нам необходимо позволить пользователю вводить и изменять запрос, а значит это состояние. Также мы будем показывать результаты поиска на экране, это тоже состояние.

В effector состояние укладывается в специальный реактивный контейнер, который называется Store (стор, муж. род). Сторы легко отличить от других сущностей по префиксу $. Давайте же определим наши состояния.

interface SearchResult {
  id: string;
  text: string;
}

const $search = createStore("");
const $results = createStore<SearchResult[]>([]);
Enter fullscreen mode Exit fullscreen mode

Стор $search автоматически вывел (type inference) тип из содержимого, переменная получила тип Store<string>.

А вот стор $results не может вывести тип из определения массива [], ведь если указать определение стора вот так createStore([]) TypeScript не сможет понять какие элементы будут лежать в этом массиве.
Поэтому effector предлагает указать тип стора явно. Еще один случай, когда нужно указать тип явно — T | null, чаще всего тип может быть выведен автоматически.

События

Если посмотреть на наш компонент, можем ли мы однозначно определить, что с ним может произойти? Что с ним может сделать пользователь?
На самом деле — да. Из постановки задачи видно, что пользователь хочет вводить запрос в поле поиска. Как определить такое событие? На самом деле сначала нужно понять как это событие назвать.

Рассмотрим пример:

<input value="" onChange={event => console.log(event.target.value)} />
Enter fullscreen mode Exit fullscreen mode

Когда пользователь вводит текст в поле поиска, мы получим лишь оповещение о вводе. Фактически произойдет событие "пользователь ввел символ". Напоминаю, что onChange вызывается для каждого нажатия символа, вставки текста, полного или частичного удаления, в общем любого фактического изменения текста в поле. Но, что важно, событие вызывается после того, как пользователь что-либо сделает с текстом.

В виду специфики controlled components значение value не будет изменено автоматически. Нам нужно будет применить изменения вручную.

Раз мы получим оповещение об уже произошедшем событии, то и называть наши события effector мы будем также в прошедшем времени.

const searchChanged = createEvent<string>();
Enter fullscreen mode Exit fullscreen mode

Мы не можем описать событие так, чтобы TypeScript автоматически вывел тип, ведь передавать данные мы будем гораздо позже этой строки. Здесь событие получает тип Event<string>. Но если тип в угловых скобках опустить, автоматически будет присвоен тип Event<void>.

Сайд-эффекты

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

Effector предлагает концепцию чистоты с которой я рекомендую ознакомиться. Но если вкратце, чистой считается та функция, которая:

  • не вызывает внешних систем
  • не модифицирует аргументы
  • при одинаковых аргументах будет давать одинаковый результат всегда
  • не зависит от внешних переменных
  • вызывает только другие чистые функции

Все редюссеры, которые применяют изменения к сторам, также должны быть чистыми. То же касается .map, filter, combine и других методов. То есть система вычислений в приложении должна быть предсказуема. Effector полагается на чистоту этих функций, иначе работа приложения будет ломаться.

Но куда же тогда размещать запросы? А генерацию рандомных значений? Получение текущей даты? Всё это называется сайд-эффектами, потому что зависит от внешних систем, от времени на компьютере от генератора случайных чисел. Для сайд-эффектов у effector есть специальная концепция — Effect. Чтобы отличить эффекты от сторов и ивентов в имени используется суффикс Fx.

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

Какой сайд-эффект есть в нашем примере? Как раз тот самый поиск. Давайте определим его, но настоящий запрос заменим примером на fetch API:

const searchFx = createEffect(async (search: string) => {
  const response = await fetch("https://someapi.com/search", {
    method: "POST",
    body: JSON.stringify({ query: search }),
  });
  if (response.ok) {
    return response.json();
  }
  throw await response.json();
});
Enter fullscreen mode Exit fullscreen mode

Здесь производится примитивная обработка ответа сервера. Любые выброшенные исключения внутри тела эффекта будут пойманы и перенаправлены в событие searchFx.fail. Если же эффект завершился успешно, то событие searchFx.done будет вызвано с результатом работы эффекта.

Конечно, многим захочется видеть выполняется ли запрос прямо сейчас, для этого создан стор searchFx.pending, на котором можно выполнить useStore(searchFx.pending) в React-компоненте и не создавать отдельный стор вручную.

Подключение

Отлично, мы описали три необходимые сущности на effector, пришло время подключить их к React. Для этого используем хуки useEvent и useStore из пакета effector-react:

import { useEvent, useStore } from 'effector-react';
import { $search, $results, searchChanged } from './model';

function Search() {
  const search = useStore($search);
  const handleSearch = useEvent(searchChanged);

  return (
    <input
      value={search}
      onChange={event => handleSearch(event.target.value)} />
  );
}
Enter fullscreen mode Exit fullscreen mode

Здесь стоит обратить внимание на тип события searchChanged — это Event<string>, тогда как onChange в React-элементе input будет присылать React.ChangeEvent<HTMLInputElement>, поэтому мы вызываем handleSearch внутри коллбека, передавая корректное значение.

Отступление на оптимизацию. React-элементам не требуется useCallback для переданных коллбеков. Но если input реализован самостоятельно и был завернут в React.memo, тогда коллбек onChange будет вынуждать компонент полностью перерендериваться каждый раз, даже если это не требуется, ведь функция на каждом рендере пересоздается.

В этом случае рекомендуется завернуть вызов в useCallback:

function Search() {
  const search = useStore($search);
  const handleSearch = useEvent(searchChanged);
  const onSearchChange = React.useCallback((event) => {
    handleSearch(event.target.value);
  }, [handleSearch]);

  return <MyInput value={search} onChange={onSearchChange} />;
}
Enter fullscreen mode Exit fullscreen mode

Но мы все еще не описали результаты поиска. Здесь стоит сделать маленькое отступление. React требует простановки ключей каждому элементу, но чаще всего если список не сортируется между рендерами, можно вместо настоящих ключей использовать индексы. В пакете effector-react есть хук useList который выполнит нужные оптимизации за вас:

function SearchResults() {
  const results = useList($results, (result) => (
    <div>{result.text}</div>
  ));

  return <div>{results}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Если все же хочется проставлять ключи каждому элементу, в документации есть параметр getKey.

Добавьте отображение процесса загрузки результатов самостоятельно, ведь вы уже знаете, что у эффекта есть стор .pending.

Соединение

Теперь компоненты подключены к effector юнитам, осталось соединить логику между собой, ведь если начать вводить текст в поле ввода, ничего не произойдет. А все потому, что стор $search никак не меняется.

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

// юниты созданы где-то здесь

$search.on(searchChanged, (_, search) => search);
Enter fullscreen mode Exit fullscreen mode

Параметром _ обозначено текущее значение стора просто потому что сейчас оно не используется.
Важно! Не стоит именовать любые параметры как state, data, payload и другими словами без контекста. В effector нет сторов с кучей обобщенных данных, а также событий присылающих с десяток данных разного вида. Обычно при определении понятно какие данные будут прилетать, так и стоит именовать аргументы. Приведу пример с нетипичным счетчиком:

const incremented = createEvent<number>();
const $counter = createStore(0);

$counter.on(incremented, (current, incrementOn) => current + incrementOn);
Enter fullscreen mode Exit fullscreen mode

Теперь значение поля поиска будет изменяться, но пока что мы никак не вызывали наш эффект searchFx, надо исправлять. И для этого нужно прочесть немного документации метода sample. Но если вкратце:

sample({
  clock,
  source,
  target,
});
Enter fullscreen mode Exit fullscreen mode

Он позволяет вызвать юнит target при срабатывании юнита clock, а еще при этом взять с собой данные из source. На самом деле возможностей побольше, ведь можно добавить условие через filter, преобразовать данные в fn и вызывать сразу несколько юнитов. Но сейчас нам столько не нужно, хорошо, что все эти возможности опциональны.

Рассмотрим простейший код:

sample({
  clock: $search,
  target: searchFx,
});
Enter fullscreen mode Exit fullscreen mode

Теперь при любом изменении стора $search будет вызван эффект searchFx, а ему в аргументы прилетит значение стора. Но такой подход далеко не всегда удобен, ведь изменять значение стора мы можем разными способами, а захотим ли мы при этом вызывать поиск, это вопрос. В данном случае нет, поиск вызывается только когда пользователь вводит что-то в поле:

sample({
  clock: searchChanged,
  source: $search,
  target: searchFx,
});
Enter fullscreen mode Exit fullscreen mode

Эту запись можно прочитать так: когда сработает searchChanged возьми данные из $search и отправь в searchFx. Но как мы помним из определения задачи, не нужно вызывать эффект на каждое нажатие символа, здесь нужен debounce.

Debounce

В экосистеме эффектора есть библиотека готовых методов, чтобы гарантировать разработчику правильную реализацию и не вынуждать писать все самому — patronum. Эти методы нужны далеко не всегда и не всем, поэтому они вынесены в отдельную библиотеку.

Собственно patronum предлагает нам готовый метод — debounce, воспользуемся им, чтобы отложить запуск эффекта:

const performSearch = debounce({ source: searchChanged, timeout: 500 });
Enter fullscreen mode Exit fullscreen mode

Теперь, когда сработает searchChanged метод подождет 500 мс и вызовет performSearch, но только если searchChanged не будет вызван еще раз, тогда будет ждать снова. А теперь вернемся к нашему sample:

sample({
  clock: performSearch,
  source: $search,
  target: searchFx,
})
Enter fullscreen mode Exit fullscreen mode

Теперь же все будет работать как надо. Осталось сохранять результаты поиска в стор $results:

$results.on(searchFx.done, (_, { result }) => result)
Enter fullscreen mode Exit fullscreen mode

Мы здесь описали объект потому что событие .done вызывается в определенном формате — { params, result }, где params это с каким аргументом был вызван эффект.
Аналогичным образом работает событие .fail{ params, error }.

Но, так как параметры нужны далеко не всегда, эффектор предлагает сокращение для таких случаев: .doneData и .failData.

Теперь всё будет работать как ожидается.


Полный код модели:

import { createStore, createEvent, createEffect, sample } from 'effector';
import { debounce } from 'patronum';

export interface SearchResult {
  id: string;
  text: string;
}

export const searchChanged = createEvent<string>();

export const $search = createStore("");
export const $results = createStore<SearchResult[]>([]);

export const searchFx = createEffect(async (search: string) => {
  const response = await fetch("https://someapi.com/search", {
    method: "POST",
    body: JSON.stringify({ query: search }),
  });
  if (response.ok) {
    return response.json();
  }
  throw await response.json();
});

$search.on(searchChanged, (_, search) => search);

const performSearch = debounce({ source: searchChanged, timeout: 500 });

sample({
  clock: performSearch,
  source: $search,
  target: searchFx,
})

$results.on(searchFx.doneData, (_, results) => results)
Enter fullscreen mode Exit fullscreen mode

Полный код страницы с React-компонентами:

import { useEvent, useStore, useList } from 'effector-react';
import { $search, $results, searchChanged } from './model';

function Search() {
  const search = useStore($search);
  const handleSearch = useEvent(searchChanged);

  return (
    <input
      value={search}
      onChange={event => handleSearch(event.target.value)} />
  );
}

function SearchResults() {
  const results = useList($results, (result) => (
    <div>{result.text}</div>
  ));

  return <div>{results}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Спасибо, за внимание! ❤️

Discussion (0)