Управление состоянием

Вся работа с состоянием происходит с помощью сторов, и ключевая особенность, что у сторов нету привычного setState. Стор обновляется реактивно при срабатывании события, на которое он подписывается, например:

import { createStore, createEvent } from "effector";
const $counter = createStore(0);
const incremented = createEvent();
// при каждом вызове incremented отработает переданный колбэк
$counter.on(incremented, (counterValue) => counterValue + 1);
incremented(); // $counter = 1
incremented(); // $counter = 2

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

Иммутабельность данных

Если вы храните в сторе ссылочный тип данных, например массив или объект, то для обновления такого стора вы можете использовать immer или сначала создать новый инстанс этого типа:

// ✅ Все круто
// обновление массива
$users.on(userAdded, (users, newUser) => {
const updatedUsers = [...users];
updatedUsers.push(newUser);
return updatedUsers;
});
// обновление объекта
$user.on(nameChanged, (user, newName) => {
const updatedUser = { ...user };
updatedUser.name = newName;
return updatedUser;
});
// ❌ А тут все плохо
$users.on(userAdded, (users, newUser) => {
users.push(newUser); // мутируем массив
return users;
});
$user.on(nameChanged, (user, newName) => {
user.name = newName; // мутируем объект
return user;
});

Создание стора

Создание стора происходит при помощи метода createStore:

import { createStore } from "effector";
// создание стора с начальным значением
const $counter = createStore(0);
// и с явной типизацией
const $user = createStore<{ name: "Bob"; age: 25 } | null>(null);
const $posts = createStore<Post[]>([]);
Наименование сторов

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

Чтение значений

Как вы уже знаете effector это реактивный стейт менеджер, а стор – реактивный юнит и реактивность создается не магическим образом. Если вы попробуете просто использовать стор, например:

import { createStore } from "effector";
const $counter = createStore(0);
console.log($counter);

Вы увидите непонятный объект с кучей свойств, который необходим effector для корректной работы, но не текущее значение. Чтобы получить текущее значение стора, есть несколько способов:

  1. Скорее всего вы также используете какой-нибудь фреймворк React, Vue или Solid и тогда вам нужен адаптер под этот фреймворк effector-react, effector-vue или effector-solid. Каждый из этих пакетов предоставляет хук useUnit для получения данных из стора, а также подписки на его изменения. При работе с UI это единственный верный способ для чтения данных:
import { useUnit } from 'effector-react'
import { $counter } from './model.js'
const Counter = () => {
const counter = useUnit($counter)
return <div>{counter}</div>
}
  1. Поскольку для построения вашей логики вне UI вам также может понадобится данные стора, вы можете использовать метод sample и передать стор в source, например:
import { createStore, createEvent, sample } from "effector";
const $counter = createStore(0);
const incremented = createEvent();
sample({
clock: incremented,
source: $counter,
fn: (counter) => {
console.log("Counter value:", counter);
},
});
incremented();

Мы чуть попозже еще обсудим метод sample и как можно с ним работать используя сторы.

  1. Можно подписаться на изменения стора через watch, однако это используется скорее для дебага либо каких-то самописных интеграций:
$counter.watch((counter) => {
console.log("Counter changed:", counter);
});
  1. Метод getState(), используется, как правило, только для работы с низкоуровневым API или интеграций. Старайтесь не использовать его в вашем коде, потому что может привести к гонке данных:
console.log($counter.getState()); // 0
Почему не использовать getState?

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

Обновление состояния

Как говорилось ранее, обновление состояния происходит при помощи событий. Можно подписаться стором на события с помощью метода .on – хорош для примитивных реакций, или оператора sample – позволяет обновить стор в зависимости от другого стора, или фильтровать обновления.

что такое sample?

Метод sample это оператор для связи между юнитами, с его помощью можно вызывать события или эффекты, а также записывать в сторы новые значения. Алгоритм его работы простой:

const trigger = createEvent();
const log = createEvent<string>();
sample({
clock: trigger, // 1. когда trigger сработает
source: $counter, // 2. возьми значение из $counter
filter: (counter) => counter % 2 === 0, // 3. если значение четное
fn: (counter) => "Counter is even: " + counter, // 4. преобразуй его
target: log, // 5. вызови и передай в log
});

С помощью .on

С помощью .on мы можем обновить стор примитивным способом: вызвалось событие -> вызови колбэк -> обнови стор возвращаемым значением:

import { createStore, createEvent } from "effector";
const $counter = createStore(0);
const incrementedBy = createEvent<number>();
const decrementedBy = createEvent<number>();
$counter.on(incrementedBy, (counterValue, delta) => counterValue + delta);
$counter.on(decrementedBy, (counterValue, delta) => counterValue - delta);
incrementedBy(11); // 0+11=11
incrementedBy(39); // 11+39=50
decrementedBy(25); // 50-25=25

С помощью sample

С методом sample мы можем как примитивно обновить стор:

import { sample } from "effector";
sample({
clock: incrementedBy, // когда сработает incrementedBy
source: $counter, // возьми данные из $counter
fn: (counter, delta) => counter + delta, // вызови колбэк fn
target: $counter, // обнови $counter возвращаемым значением из fn
});
sample({
clock: decrementedBy, // когда сработает decrementedBy
source: $counter, // возьми данные из $counter
fn: (counter, delta) => counter - delta, // вызови колбэк fn
target: $counter, // обнови $counter возвращаемым значением из fn
});

так и имеем более гибкие способы, например обновить стор только когда другой стор имеет нужное значение, к примеру искать только когда $isSearchEnabled имеет значение true:

import { createStore, createEvent, sample } from "effector";
const $isSearchEnabled = createStore(false);
const $searchQuery = createStore("");
const $searchResults = createStore<string[]>([]);
const searchTriggered = createEvent();
sample({
clock: searchTriggered, // когда сработает searchTriggered
source: $searchQuery, // возьми данные из $searchQuery
filter: $isSearchEnabled, // если поиск активен то продолжаем
fn: (query) => {
// имитируем поиск
return ["result1", "result2"].filter((item) => item.includes(query));
},
target: $searchResults, // обнови $searchResults возвращаемым значением из fn
});

Заметьте, что при передаче стора в target его предыдущее значение будет полностью заменено на возвращаемое значение из fn.

Обновление от нескольких событий

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

import { createEvent, createStore } from "effector";
const $lastUsedFilter = createStore<string | null>(null);
const $filters = createStore({
category: "all",
searchQuery: "",
});
const categoryChanged = createEvent<string>();
const searchQueryChanged = createEvent<string>();
// подписываемся двумя разными сторами на одно и то же событие
$lastUsedFilter.on(categoryChanged, (_, category) => category);
sample({
clock: categoryChanged,
source: $filters,
fn: (filters, category) => ({
// придерживаемся принципа иммутабельности
...filters,
category,
}),
// результат fn заменит предыдущее значение в $filters
target: $filters,
});
// а также подписываемся стором на два события searchQueryChanged и categoryChanged
sample({
clock: searchQueryChanged,
source: $filters,
fn: (filters, searchQuery) => ({
// придерживаемся принципа иммутабельности
...filters,
searchQuery,
}),
// результат fn заменит предыдущее значение в $filters
target: $filters,
});

Мы подписались двумя сторами на одно и то же событие categoryChanged, а также стором $filters на еще одно событие searchQueryChanged.

Производные сторы

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

import { createStore } from "effector";
const $author = createStore({
name: "Hanz Zimmer",
songs: [
{ title: "Time", likes: 123 },
{ title: "Cornfield Chase", likes: 97 },
{ title: "Dream is Collapsing", likes: 33 },
],
});

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

При такой логике правильным подходом будет создать производные сторы на основе $author используя метод combine:

import { createStore, combine } from "effector";
const $author = createStore({
name: "Hanz Zimmer",
songs: [
{ title: "Time", likes: 123 },
{ title: "Cornfield Chase", likes: 97 },
{ title: "Dream is Collapsing", likes: 33 },
],
});
// общее количество песен
const $totalSongsCount = combine($author, (author) => author.songs.length);
// общее количество лайков
const $totalLikesCount = combine($author, (author) =>
author.songs.reduce((acc, song) => acc + song.likes, 0),
);

Каждый из производных сторов будет автоматически обновляться при изменении исходного стора $author.

Важно про производные сторы!

Производные сторы обновляются автоматически при изменении исходных сторов, их нельзя передать в target у sample или подписаться на событие через .on.

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

import { combine, createStore } from "effector";
const $isLoading = createStore(false);
const $isSuccess = createStore(false);
const $error = createStore<string | null>(null);
const $isAppReady = combine($isLoading, $isSuccess, $error, (isLoading, isSuccess, error) => {
return !isLoading && isSuccess && !error;
});

Значения undefined

Если вы попробуете использовать значение стора как undefined или положите в стор это значение:

const $store = createStore(0).on(event, (_, newValue) => {
if (newValue % 2 === 0) {
return undefined;
}
return newValue;
});

то столкнетесь с ошибкой в консоли:

Terminal window
store: undefined is used to skip updates. To allow undefined as a value provide explicit { skipVoid: false } option

По умолчанию возвращение undefined служит как команда “ничего не произошло, пропусти это обновление”. Если вам действительно нужно использовать undefined как валидное значение, тогда необходимо явно указать это с помощью параметра skipVoid: false при создании стора:

import { createStore } from "effector";
const $store = createStore(0, {
skipVoid: false,
});
Будущее undefined

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

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

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

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

Соавторы