Unit composition

If we consider each unit as a building block of our application, then for full functionality we need to somehow glue these blocks together. For example, when a form submission event occurs, validate the form data, if everything is correct, call an effect to send the data, and also update our store. In other words, build connections between units. To implement this, you need to use the sample operator or createAction.

Conceptually, both operators perform the same work, however, they have a difference: sample is a declarative operator, while createAction is more imperative, allowing you to describe logic in a more familiar style.

// 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();
}
},
});

Both operators trigger when the sendButtonClicked event is called, then take data from source, and then:

  • In sample, separate parameters are used: filter for checking conditions, fn for data transformation, and target for calling units.
  • In createAction, all logic is in a single fn, where you can use regular if statements for conditions and explicitly call the needed target.
action

createAction is an operator from the external package effector-action, which will move to the effector core package in the nearest major release. Additionally, you need to install the patronum package.

Terminal window
npm install effector-action patronum

Basic usage

Let’s look at a basic example from the beginning of the article: we want to validate form data when a form submission event occurs, call an effect to send the data if everything is correct, and also update our store. Let’s first look at what units we need:

  • We need an event submitForm for form submission
  • Several stores - $formData to store form data and $formSubmitted for the form submission status
  • And an effect sendFormFx to send data to the server
Why not call the effect directly from UI?

On the How to think in the effector paradigm page, we explain why you should create events rather than just calling effects directly from 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 }) => {
// some logic to send data to the server
});

In the UI, we will call the submitForm event when the user clicks the submit button. It remains to build connections between units:

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 }) => {
// some logic to send data to the server
});
sample({
clock: submitForm,
source: $formData,
filter: (form) => form.age >= 18 && form.username.length > 0,
target: sendFormFx,
});
sample({
clock: submitForm,
fn: () => true,
target: $formSubmitted,
});

Usage capabilities

As mentioned, both operators are conceptually similar to each other, so you don’t need to choose one over the other - you can use both in your application. However, there are some cases when createAction will be preferable over sample:

  1. Conditional execution logic. When using sample, you may encounter difficulty in narrowing types after filter, which is not the case when using createAction due to the use of native language constructs that TypeScript understands well - if.
  2. Grouping by trigger. It’s also more convenient to use createAction when we have one common trigger, but different calculations are required for each target.

Let’s now look at the main operator usage capabilities:

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,
});
  • You can also perform calculations in the fn function, but keep in mind that it must be a pure function and synchronous.

Limitations of createAction

The createAction operator has an important restriction: when calling the same target multiple times, only the last one will be invoked:

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);
// only the last target call will be executed
target(delta + 5);
},
});

How to use these operators

Using these operators involves building atomic connections instead of one large block of code. For example, let’s consider another application scenario - a search form with parameters. Let’s first look at how we would write this in vanilla JavaScript: Suppose we have some state in a UI framework:

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

Functions to change state:

function handleQueryChanged(payload) {
// here can be any state change from React/Vue/Solid and other frameworks
state.query = payload;
}
function handleCategoryChanged(payload) {
// here can be any state change from React/Vue/Solid and other frameworks
state.category = payload;
}

And the main function for requesting data:

async function handleSearchClick() {
state.error = null;
state.results = [];
state.isLoading = true;
try {
const currentQuery = state.query;
const currentCategory = state.category;
// some API call
const data = await apiCall(currentQuery, currentCategory);
state.results = data;
} catch (e) {
state.error = e.message;
} finally {
state.isLoading = false;
}
}

All that’s left is to call these functions in the UI at the right moment. With the sample or createAction operators, things work a bit differently - we will create atomic independent connections between units. First, let’s rewrite the previous code using units:

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);

We need events to change stores and also add logic to change these stores:

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,
});

And now we need to implement the main search logic:

model.ts
async function handleSearchClick() {
state.error = null;
state.results = [];
state.isLoading = true;
try {
const currentQuery = state.query;
const currentCategory = state.category;
// some API call
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(query, category);
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,
});

In the final form, we will have the following data model:

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,
});
Contributors