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

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

Концептуально оба оператора выполняют одинаковую работу, однако у них есть отличие: sample – декларативный оператор, в то время как createAction – более императивный, который позволяет в более привычном стиле описывать логику работу.

// sample
import { sample, createEvent } from "effector";
const sendButtonClicked = createEvent();
sample({
clock: sendButtonClicked,
source: $formData,
filter: (form) => form.username.length > 0 && form.age >= 18,
fn: (form) => ({
...form,
timestamp: Date.now(),
}),
target: [sendFormFx, formSubmitted],
});
// createAction
import { createAction } from "effector-action";
import { createEvent } from "effector";
const sendButtonClicked = createEvent();
createAction(sendButtonClicked, {
source: $formData,
target: {
sendForm: sendFormFx,
formSubmitted,
},
fn: (target, form) => {
if (form.username.length > 0 && form.age >= 18) {
const updatedForm = {
...form,
timestamp: Date.now(),
};
target.sendForm(updatedForm);
target.formSubmitted();
}
},
});

Оба оператора срабатывают при вызове события sendButtonClicked, затем берут данные из source, а дальше:

  • В sample используются отдельные параметры: filter для проверки условий, fn для трансформации данных, и target для вызова юнитов.
  • В createAction вся логика находится в одной fn, где можно использовать обычные if для условий и явно вызывать нужные target.
action

createAction является оператором из внешнего пакета effector-action, который в ближайшем мажоре переедет в кор пакет effector. Также в дополнение нужно установить пакет patronum.

Terminal window
npm install effector-action patronum

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

Давайте рассмотрим базовый пример на примере из начала статьи: мы хотим при срабатывании события подтверждении формы, провалидировать данные формы, если все корректно вызвать эффект с отправкой данных, а также обновить наш стор. Давай сначала рассмотрим какие юниты нам нужны:

  • Нам нужно событие submitForm для отправки формы
  • Несколько сторов$formData для хранения данных формы и $formSubmitted для статуса отправки формы
  • И эффект sendFormFx чтобы отправлять данные на сервер
Почему не вызывать эффект напрямую из UI?

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

import { createEvent, createStore, sample, createEffect } from "effector";
const submitForm = createEvent();
const $formData = createStore({ username: "", age: 0 });
const $formSubmitted = createStore(false);
const sendFormFx = createEffect((formData: { username: string; age: number }) => {
// какая-то логика отправки данных на сервер
});

В UI мы будем вызывать событие submitForm, когда пользователь нажмет на кнопку отправки формы. Осталось построить связи между юнитами:

import { createEvent, createStore, sample, createEffect } from "effector";
const submitForm = createEvent();
const $formData = createStore({ username: "", age: 0 });
const $formSubmitted = createStore(false);
const sendFormFx = createEffect((formData: { username: string; age: number }) => {
// какая-то логика отправки данных на сервер
});
sample({
clock: submitForm,
source: $formData,
filter: (form) => form.age >= 18 && form.username.length > 0,
target: sendFormFx,
});
sample({
clock: submitForm,
fn: () => true,
target: $formSubmitted,
});

Возможности использования

Как и говорилось оба оператора концептуально схожи друг с другом, поэтому вам не нужно делать выбор в пользу какого-либо из них, вы можете использовать в приложении и тот и другой, однако есть некоторые кейсы, когда createAction будет приоритетнее sample:

  1. Условная логика выполнения. При использовании sample может возникнуть сложность в сужении типов после filter, чего нет при использовании createAction за счет использования нативной конструкция языка, которую TypeScript отлично понимает – if.
  2. Группировка по триггеру. Использовать createAction также удобнее, когда у нас имеется один общий триггер, но требуется разные вычисления для каждого из target

Давайте теперь рассмотрим основные возможности использования операторов:

import { createEvent, createStore, sample } from "effector";
const $query = createStore("");
const queryChanged = createEvent<string>();
sample({
clock: queryChanged,
target: $query,
});
import { createEvent, createStore, sample } from "effector";
const $query = createStore("");
const $shouldUpdate = createStore(false);
const queryChanged = createEvent<string>();
sample({
clock: queryChanged,
filter: $shouldUpdate,
target: $query,
});
  • Вы также можете производить вычисления в fn функции, однако держите в голове, что это должно быть чистой функцией, а также синхронной.

Ограничения createAction

У оператора createAction есть важное ограничение: при вызове одного и того же target несколько раз, только последний будет вызван:

import { createStore, createEvent } from "effector";
import { createAction } from "effector-action";
const $counter = createStore(0);
const increase = createEvent<number>();
createAction(increase, {
target: $counter,
fn: (target, delta) => {
target(delta);
// отработает только последний вызов target
target(delta + 5);
},
});

Как использовать эти операторы

Использование этих операторов подразумевает построение атомарных связей вместо одного крупного блока кода. Для примера давайте рассмотрим еще один сценарий приложения – форма поиска с параметрами. Давайте в начале посмотрим как бы такое мы писали на ванильном js коде: Предположим у нас есть какой-то стейт в UI фреймворке:

const state = {
query: "",
category: "all",
results: [],
isLoading: false,
error: null,
};

Функции для изменения стейта:

function handleQueryChanged(payload) {
// здесь может быть любое изменение стейта из React/Vue/Solid и других фреймворков
state.query = payload;
}
function handleCategoryChanged(payload) {
// здесь может быть любое изменение стейта из React/Vue/Solid и других фреймворков
state.category = payload;
}

И основная функция для запроса данных:

async function handleSearchClick() {
state.error = null;
state.results = [];
state.isLoading = true;
try {
const currentQuery = state.query;
const currentCategory = state.category;
// какой-то вызов api
const data = await apiCall(currentQuery, currentCategory);
state.results = data;
} catch (e) {
state.error = e.message;
} finally {
state.isLoading = false;
}
}

Осталось только в UI вызывать эти функции в нужный момент. С помощью операторов sample или createAction работа обстоит чуть иначе, мы будем создавать атомарные независимые связи между юнитами. Для начала перепишем предыдущий код на юниты:

model.ts
const state = {
query: "",
category: "all",
results: [],
isLoading: false,
error: null,
};
const $query = createStore("");
const $category = createStore("all");
const $results = createStore([]);
const $error = createStore(null);
const $isLoading = createStore(false);

Нам нужны события для изменения сторов и также добавить логику изменения этих сторов:

model.ts
function handleQueryChanged(payload) {
state.query = payload;
}
function handleCategoryChanged(payload) {
state.category = payload;
}
const queryChanged = createEvent<string>();
const categoryChanged = createEvent<string>();
sample({
clock: queryChanged,
target: $query,
});
sample({
clock: categoryChanged,
target: $category,
});

И теперь нам нужно также реализовать основную логику поиска:

model.ts
async function handleSearchClick() {
state.error = null;
state.results = [];
state.isLoading = true;
try {
const currentQuery = state.query;
const currentCategory = state.category;
// какой-то вызов api
const data = await apiCall(currentQuery, currentCategory);
state.results = data;
} catch (e) {
state.error = e.message;
} finally {
state.isLoading = false;
}
}
const searchClicked = createEvent();
const searchFx = createEffect(async ({ query, category }) => {
const data = await apiCall(currentQuery, currentCategory);
return data;
});
sample({
clock: searchClicked,
source: {
query: $query,
category: $category,
},
target: searchFx,
});
sample({
clock: searchFx.$pending,
target: $isLoading,
});
sample({
clock: searchFx.failData,
fn: (error) => error.message,
target: $error,
});
sample({
clock: searchFx.doneData,
target: $results,
});

В итоговом виде мы будем иметь такую модель данных:

model.ts
import { createStore, createEvent, createEffect, sample } from "effector";
const $query = createStore("");
const $category = createStore("all");
const $results = createStore([]);
const $error = createStore(null);
const $isLoading = createStore(false);
const queryChanged = createEvent<string>();
const categoryChanged = createEvent<string>();
const searchClicked = createEvent();
const searchFx = createEffect(async ({ query, category }) => {
const data = await apiCall(query, category);
return data;
});
sample({
clock: queryChanged,
target: $query,
});
sample({
clock: categoryChanged,
target: $category,
});
sample({
clock: searchClicked,
source: {
query: $query,
category: $category,
},
target: searchFx,
});
sample({
clock: searchFx.$pending,
target: $isLoading,
});
sample({
clock: searchFx.failData,
fn: (error) => error.message,
target: $error,
});
sample({
clock: searchFx.doneData,
target: $results,
});
Перевод поддерживается сообществом

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

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

Соавторы