Задача очень простая на первый взгляд:
собрать компонент поиска по сайту, который будет выполнять поиск в соответствии с тем, как пользователь вводит запрос.
Первое, что сразу же приходит на ум — нельзя выполнять поиск после каждого нового введенного символа. Прежде чем, отправлять запрос на сервер, нужно дождаться пока пользователь закончит ввод запроса, иначе пользователь увидит не релевантные результаты на странице, а сервер получит пару десятков запросов, ответы на которые будут выброшены.
В 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>
);
}
Здесь нет фактической реализации debounce и выполнения запроса на сервер. Я оставлю ссылку здесь на статью с примером реализации.
Проектирование подобного компонента стоит начать с простой техники: Состояния, События, Сайд-эффекты. Именно в таком порядке определяем необходимые для данной бизнес-логики сущности, которые будем описывать на языке effector. Конечно, в процессе нам могут понадобится дополнительные юниты и мы и опишем. Прямо сейчас мы работаем с целевым API нашей модели.
Состояния
Какие состояния нужны для определения нашего поиска? Нам необходимо позволить пользователю вводить и изменять запрос, а значит это состояние. Также мы будем показывать результаты поиска на экране, это тоже состояние.
В effector состояние укладывается в специальный реактивный контейнер, который называется Store (стор, муж. род). Сторы легко отличить от других сущностей по префиксу $
. Давайте же определим наши состояния.
interface SearchResult {
id: string;
text: string;
}
const $search = createStore("");
const $results = createStore<SearchResult[]>([]);
Стор $search
автоматически вывел (type inference) тип из содержимого, переменная получила тип Store<string>
.
А вот стор $results
не может вывести тип из определения массива []
, ведь если указать определение стора вот так createStore([])
TypeScript не сможет понять какие элементы будут лежать в этом массиве.
Поэтому effector предлагает указать тип стора явно. Еще один случай, когда нужно указать тип явно — T | null
, чаще всего тип может быть выведен автоматически.
События
Если посмотреть на наш компонент, можем ли мы однозначно определить, что с ним может произойти? Что с ним может сделать пользователь?
На самом деле — да. Из постановки задачи видно, что пользователь хочет вводить запрос в поле поиска. Как определить такое событие? На самом деле сначала нужно понять как это событие назвать.
Рассмотрим пример:
<input value="" onChange={event => console.log(event.target.value)} />
Когда пользователь вводит текст в поле поиска, мы получим лишь оповещение о вводе. Фактически произойдет событие "пользователь ввел символ". Напоминаю, что onChange
вызывается для каждого нажатия символа, вставки текста, полного или частичного удаления, в общем любого фактического изменения текста в поле. Но, что важно, событие вызывается после того, как пользователь что-либо сделает с текстом.
В виду специфики controlled components значение
value
не будет изменено автоматически. Нам нужно будет применить изменения вручную.
Раз мы получим оповещение об уже произошедшем событии, то и называть наши события effector мы будем также в прошедшем времени.
const searchChanged = createEvent<string>();
Мы не можем описать событие так, чтобы 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();
});
Здесь производится примитивная обработка ответа сервера. Любые выброшенные исключения внутри тела эффекта будут пойманы и перенаправлены в событие 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)} />
);
}
Здесь стоит обратить внимание на тип события 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} />;
}
Но мы все еще не описали результаты поиска. Здесь стоит сделать маленькое отступление. React требует простановки ключей каждому элементу, но чаще всего если список не сортируется между рендерами, можно вместо настоящих ключей использовать индексы. В пакете effector-react
есть хук useList
который выполнит нужные оптимизации за вас:
function SearchResults() {
const results = useList($results, (result) => (
<div>{result.text}</div>
));
return <div>{results}</div>;
}
Если все же хочется проставлять ключи каждому элементу, в документации есть параметр getKey
.
Добавьте отображение процесса загрузки результатов самостоятельно, ведь вы уже знаете, что у эффекта есть стор
.pending
.
Соединение
Теперь компоненты подключены к effector юнитам, осталось соединить логику между собой, ведь если начать вводить текст в поле ввода, ничего не произойдет. А все потому, что стор $search
никак не меняется.
Обычно я советую писать все определения юнитов вверху файла модели, а снизу писать логику в том порядке в котором вы предполагаете она будет срабатывать.
// юниты созданы где-то здесь
$search.on(searchChanged, (_, search) => search);
Параметром _
обозначено текущее значение стора просто потому что сейчас оно не используется.
Важно! Не стоит именовать любые параметры как state
, data
, payload
и другими словами без контекста. В effector нет сторов с кучей обобщенных данных, а также событий присылающих с десяток данных разного вида. Обычно при определении понятно какие данные будут прилетать, так и стоит именовать аргументы. Приведу пример с нетипичным счетчиком:
const incremented = createEvent<number>();
const $counter = createStore(0);
$counter.on(incremented, (current, incrementOn) => current + incrementOn);
Теперь значение поля поиска будет изменяться, но пока что мы никак не вызывали наш эффект searchFx
, надо исправлять. И для этого нужно прочесть немного документации метода sample
. Но если вкратце:
sample({
clock,
source,
target,
});
Он позволяет вызвать юнит target
при срабатывании юнита clock
, а еще при этом взять с собой данные из source
. На самом деле возможностей побольше, ведь можно добавить условие через filter
, преобразовать данные в fn
и вызывать сразу несколько юнитов. Но сейчас нам столько не нужно, хорошо, что все эти возможности опциональны.
Рассмотрим простейший код:
sample({
clock: $search,
target: searchFx,
});
Теперь при любом изменении стора $search
будет вызван эффект searchFx
, а ему в аргументы прилетит значение стора. Но такой подход далеко не всегда удобен, ведь изменять значение стора мы можем разными способами, а захотим ли мы при этом вызывать поиск, это вопрос. В данном случае нет, поиск вызывается только когда пользователь вводит что-то в поле:
sample({
clock: searchChanged,
source: $search,
target: searchFx,
});
Эту запись можно прочитать так: когда сработает searchChanged
возьми данные из $search
и отправь в searchFx
. Но как мы помним из определения задачи, не нужно вызывать эффект на каждое нажатие символа, здесь нужен debounce.
Debounce
В экосистеме эффектора есть библиотека готовых методов, чтобы гарантировать разработчику правильную реализацию и не вынуждать писать все самому — patronum. Эти методы нужны далеко не всегда и не всем, поэтому они вынесены в отдельную библиотеку.
Собственно patronum предлагает нам готовый метод — debounce, воспользуемся им, чтобы отложить запуск эффекта:
const performSearch = debounce({ source: searchChanged, timeout: 500 });
Теперь, когда сработает searchChanged
метод подождет 500 мс и вызовет performSearch
, но только если searchChanged
не будет вызван еще раз, тогда будет ждать снова. А теперь вернемся к нашему sample
:
sample({
clock: performSearch,
source: $search,
target: searchFx,
})
Теперь же все будет работать как надо. Осталось сохранять результаты поиска в стор $results
:
$results.on(searchFx.done, (_, { result }) => result)
Мы здесь описали объект потому что событие .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)
Полный код страницы с 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>;
}
Спасибо, за внимание! ❤️
Top comments (2)
Super bomba thank you bro
Хорошая статья для новичков в effector. Узнал про debounce и patronum. Спасибо!