Композиция юнитов в effector

В Effector есть два мощных инструмента для связывания юнитов между собой: sample и attach. Хотя они могут показаться похожими, у каждого из них есть свои особенности и сценарии использования.

Sample: связь данных и событий

sample - это универсальный инструмент для связывания юнитов. Его главная задача - брать данные из одного места source и передавать их в другое место target при срабатывании определённого триггера clock.

Общий паттерн работы метода sample следующий:

  1. Сработай при вызове тригера clock
  2. Возьми данные из source
  3. Отфильтруй данные, если все корректно, то верни true и иди дальше по цепочке, иначе false
  4. Преобразуй данные при помощи fn
  5. Отдай данные в target.

Базовое использование sample

import { createStore, createEvent, sample, createEffect } from "effector";

const buttonClicked = createEvent();

const $userName = createStore("Bob");

const fetchUserFx = createEffect((userName) => {
  // логика
});

// При клике на кнопку получаем текущее имя
sample({
  clock: buttonClicked,
  source: $userName,
  target: fetchUserFx,
});
Универсальность sample

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

import { createStore, sample } from "effector";

const $currentUser = createStore({ name: "Bob", age: 25 });

// создает производный стор, который обновляется, когда source меняется
const $userAge = sample({
  source: $currentUser,
  fn: (user) => user.age,
});
// эквивалентно
const $userAgeViaMap = $currentUser.map((currentUser) => currentUser.age);

Как вы можете заметить метод sample очень гибкий и может использоваться в различных сценариях:

  • Когда нужно взять данные из хранилища в момент события
  • Для трансформации данных перед отправкой
  • Для условной обработки через filter
  • Для синхронизации нескольких источников данных
  • Последовательная цепочка запуска юнитов

Фильтрация данных

Вам может потребоваться запустить цепочку вызова, при выполнение каких-то условий, для таких ситуаций метод sample позволяет фильтровать данные с помощью параметра filter:

import { createEvent, createStore, sample, createEffect } from "effector";

type UserFormData = {
  username: string;
  age: number;
};

const submitForm = createEvent();

const $formData = createStore<UserFormData>({ username: "", age: 0 });

const submitToServerFx = createEffect((formData: UserFormData) => {
  // логика
});

sample({
  clock: submitForm,
  source: $formData,
  filter: (form) => form.age >= 18 && form.username.length > 0,
  target: submitToServerFx,
});

submitForm();

При вызове submitForm мы берем данные из source, проверем в filter по условиям, если проверка прошла успешно, то возвращаем true и вызываем target, в ином случае false и ничего больше не делаем.

Важная информация

Функции fn и filter должны быть чистыми функциями! Чистая функция - это функция, которая всегда возвращает один и тот же результат для одинаковых входных данных и не производит никаких побочных эффектов (не изменяет данные вне своей области видимости).

Трансформация данных

Часто нужно не просто передать данные, но и преобразовать их. Для этого используется параметр fn:

import { createEvent, createStore, sample } from "effector";

const buttonClicked = createEvent();
const $user = createStore({ name: "Bob", age: 25 });
const $userInfo = createStore("");

sample({
  clock: buttonClicked,
  source: $user,
  fn: (user) => `${user.name} is ${user.age} years old`,
  target: $userInfo,
});

Несколько источников данных

Можно использовать несколько сторов как источник данных:

import { createEvent, createStore, sample, createEffect } from "effector";

type SubmitSearch = {
  query: string;
  filters: Array<string>;
};

const submitSearchFx = createEffect((params: SubmitSearch) => {
  /// логика
});

const searchClicked = createEvent();

const $searchQuery = createStore("");
const $filters = createStore<string[]>([]);

sample({
  clock: searchClicked,
  source: {
    query: $searchQuery,
    filters: $filters,
  },
  target: submitSearchFx,
});

Несколько источников вызова sample

sample позволяет использовать массив событий в качестве clock, что очень удобно когда нам нужно обработать одинаковым образом несколько разных триггеров. Это помогает избежать дублирования кода и делает логику более централизованной.

import { createEvent, createStore, sample } from "effector";

// События для разных действий пользователя
const saveButtonClicked = createEvent();
const ctrlSPressed = createEvent();
const autoSaveTriggered = createEvent();

// Общее хранилище данных
const $formData = createStore({ text: "" });

// Эффект сохранения
const saveDocumentFx = createEffect((data: { text: string }) => {
  // Логика сохранения
});

// Единая точка сохранения документа, которая срабатывает от любого триггера
sample({
  // Все эти события будут вызывать сохранение
  clock: [saveButtonClicked, ctrlSPressed, autoSaveTriggered],
  source: $formData,
  target: saveDocumentFx,
});

Массив target в sample

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

import { createEvent, createStore, createEffect, sample } from "effector";

// Создаем юниты куда будут направляться данные
const userDataReceived = createEvent<User>();
const $lastUserData = createStore<User | null>(null);
const saveUserFx = createEffect<User, void>((user) => {
  // Сохраняем пользователя
});
const logUserFx = createEffect<User, void>((user) => {
  // Логируем действия с пользователем
});

const userUpdated = createEvent<User>();

// При обновлении пользователя:
// - Сохраняем данные через saveUserFx
// - Отправляем в систему логирования через logUserFx
// - Обновляем стор $lastUserData
// - Вызываем событие userDataReceived
sample({
  clock: userUpdated,
  target: [saveUserFx, logUserFx, $lastUserData, userDataReceived],
});

Важные моменты:

  • Все юниты в target должны быть совместимы по типу с данными из source/clock
  • Порядок выполнения целей гарантирован - они будут вызваны в порядке написания
  • Можно комбинировать разные типы юнитов в массиве target

Возвращаемое значение sample

sample возвращает юнит, тип которого зависит от конфигурации:

С target

Если указан target, sample вернёт этот же target:

const $store = createStore(0);
const submitted = createEvent();
const sendData = createEvent<number>();

// result будет иметь тип EventCallable<number>
const result = sample({
  clock: submitted,
  source: $store,
  target: sendData,
});

Без target

Когда target не указан, то тип возвращаемого значения зависит от передаваемых параметров.
Если НЕ указан filter, а также clock и source являются хранилищами, то результат будет производным хранилищем с типом данных из source.

import { createStore, sample } from "effector";

const $store = createStore("");
const $secondStore = createStore(0);

const $derived = sample({
  clock: $secondStore,
  source: $store,
});
// $derived будет Store<string>

const $secondDerived = sample({
  clock: $secondStore,
  source: $store,
  fn: () => false,
});
// $secondDerived будет Store<boolean>

Если используется fn, то тип возвращаемого значения будет соответствовать результату функции.

В остальных же случаях возвращаемое значение будет производным событием с типом данных зависящий от source, которое нельзя вызвать самому, однако можно подписаться на него!

типизация sample

Метод sample полностью типизирован, и принимает тип в зависимости от передаваемых параметров!

import { createStore, createEvent, sample } from "effector";

const $store = createStore(0);

const submitted = createEvent<string>();

const event = sample({
  clock: submitted,
  source: $store,
});
// event имеет тип Event<number>

const secondSampleEvent = sample({
  clock: submitted,
  source: $store,
  fn: () => true,
});
// Event<true>

Практический пример

Давайте рассмотрим практический пример, когда при выборе id пользователя нам нужно проверить является ли он админом, сохранить выбранного пользователя в сторе, и на основе выбранного id создать производный стор с данными о пользователе

import { createStore, createEvent, sample } from "effector";

type User = {
  id: number;
  role: string;
};

const userSelected = createEvent<number>();

const $users = createStore<User[]>([]);

// Создаём производный стор, который будет хранить выбранного пользователя
const $selectedUser = sample({
  clock: userSelected,
  source: $users,
  fn: (users, id) => users.find((user) => user.id === id) || null,
});
// $selectedUser имеет тип Store<User | null>

// Создаём производное событие, которое будет срабатывать только для админов
// если выбранный пользователь админ, то событие сработает сразу
const adminSelected = sample({
  clock: userSelected,
  source: $users,
  // сработает только если пользователь найден и он админ
  filter: (users, id) => !!users.find((user) => user.id === id && user.role === "admin"),
  fn: (users, id) => users[id],
});
// adminSelected имеет тип Event<User>

userSelected(2);

Полное API для sample

Attach: специализация эффектов

attach - это инструмент для создания новых эффектов на основе существующих, с доступом к данным из сторов. Это особенно полезно когда нужно:

  • Добавить контекст к эффекту
  • Переиспользовать логику эффекта с разными параметрами
  • Инкапсулировать доступ к стору
import { attach, createEffect, createStore } from "effector";

type SendMessageParams = { text: string; token: string };

// Базовый эффект для отправки данных
const baseSendMessageFx = createEffect<SendMessageParams, void>(async ({ text, token }) => {
  await fetch("/api/messages", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify({ text }),
  });
});

// Стор с токеном авторизации
const $authToken = createStore("default-token");

// Создаём специализированный эффект, который автоматически использует токен
const sendMessageFx = attach({
  effect: baseSendMessageFx,
  source: $authToken,
  mapParams: (text: string, token) => ({
    text,
    token,
  }),
});

// Теперь можно вызывать эффект только с текстом сообщения
sendMessageFx("Hello!"); // токен будет добавлен автоматически

Очень удобно использовать attach для переиспользования логики:

const fetchDataFx = createEffect<{ endpoint: string; token: string }, any>();

// Создаём специализированные эффекты для разных эндпоинтов
const fetchUsersFx = attach({
  effect: fetchDataFx,
  mapParams: (_, token) => ({
    endpoint: "/users",
    token,
  }),
  source: $authToken,
});

const fetchProductsFx = attach({
  effect: fetchDataFx,
  mapParams: (_, token) => ({
    endpoint: "/products",
    token,
  }),
  source: $authToken,
});

Полное API для attach

Перевод поддерживается сообществом

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

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

Соавторы