Side effects and async operations

Side effects and async operations

To work with side effects and any asynchronous logic, effector provides effects. A side effect is anything that can impact the purity of your function, for example:

  • HTTP requests to a server
  • Changing or touching global variables
  • Browser APIs such as addEventListener, setTimeout, and so on
  • Working with localStorage, IndexedDB, or other storage APIs
  • Any code that may throw or take noticeable time to complete
import { createEffect, sample, createStore } from "effector";
const $user = createStore(null);
const fetchUserFx = createEffect(async (userId: string) => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error("Failed to fetch user");
}
return response.json();
});
// when the effect succeeds we update the store with returned value
sample({
clock: fetchUserFx.doneData,
target: $user,
});

But why do we need effects at all? In effector most helpers such as store.on, sample.fn, and so on are pure β€” they work only with the arguments they receive. These functions must not be asynchronous or contain side effects because that would break predictability and reactivity.

Advantages of effects

An effect is a container for side effects or asynchronous functions that may throw or take an unknown amount of time. To connect effects to the reactive system they expose convenient properties. A few important ones:

  • pending β€” a store that shows whether the effect is running. Perfect for displaying a loader in UI.
  • doneData β€” an event fired when the effect completes successfully.
  • failData β€” another event triggered when the effect throws; it carries the thrown error.
Derived units

Every effect property is triggered by effector’s core. You must not try to call them manually.

Because effects have their own events, working with them is similar to regular events. Let’s look at a simple example:

model.ts
import { createEffect, createEvent, createStore, sample } from "effector";
export const $error = createStore<string | null>(null);
export const submit = createEvent();
// simple form submission wrapped into an effect
const sendFormFx = createEffect(async ({ name, email }: { name: string; email: string }) => {
try {
await fetch("/api/user-data", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email }),
});
} catch {
throw new Error("Failed to send form");
}
});
export const $isLoading = sendFormFx.pending;
sample({
clock: sendFormFx.failData,
fn: (error) => error.message,
target: $error,
});
sample({
clock: submit,
target: sendFormFx,
});
One argument

Effects, just like events, accept only one argument. Use an object (e.g. { name, email }) if you need to pass multiple values.

In UI we trigger the submit event, show a loader while the request is running, and display the error if there is one:

view.tsx
import { useUnit } from "effector-react";
import { $error, $isLoading, submit } from "./model.ts";
const Form = () => {
const { isLoading, error } = useUnit({
isLoading: $isLoading,
error: $error,
});
const onSubmit = useUnit(submit);
return (
<form onSubmit={onSubmit}>
<input name="name" />
<input name="email" />
<button type="submit">Submit</button>
{isLoading && <div>Loading...</div>}
{error && <div>{error}</div>}
</form>
);
};

Let’s highlight the reactive connections in the model. First we connect the failData event to the $error store so that the error message is saved whenever the effect fails:

sample({
clock: sendFormFx.failData,
fn: (error) => error.message,
target: $error,
});

When the submit event fires we just put the effect into target:

sample({
clock: submit,
target: sendFormFx,
});

Calling effects

Effect calls are identical to event calls. You can grab an effect inside a component with useUnit and call it as a function:

const fetchUser = useUnit(fetchUserFx);

However, it is better not to expose our UI layer to too much business logic. A common alternative is to export an event from the model, use it in the view, and bind that event to the effect with sample. The arguments passed to the event will be forwarded to the effect:

import { createEvent, sample } from "effector";
export const updateProfileButtonPressed = createEvent<string>();
sample({
clock: updateProfileButtonPressed,
target: fetchUserFx,
});
Why do I need an event for an effect call?

You can also call effects inside other effects:

import { createEffect } from "effector";
const fetchInitialData = createEffect(async (userId: string) => {
const userData = await getUserByIdFx(userId);
const friends = await getUserByIds(userData.friends);
return userData.name;
});

Calling events inside an effect

It is also possible to trigger events inside an effect β€” handy when you need to emit something on a timer:

import { createEffect, createEvent } from "effector";
const tick = createEvent();
const fetchInitialData = createEffect(async () => {
// remember to clean up the id later!
const id = setInterval(() => {
tick();
}, 1000);
});

If you work with scopes, this pattern may lead to scope loss. In that case wrap the handler with scopeBind. No extra work is required outside of scoped environments.

Update a store when the effect completes

A classic scenario is updating a store once the effect finishes. The logic is the same as with events: subscribe to doneData and point the target to the desired store:

import { createStore, createEffect } from "effector";
const fetchUserNameFx = createEffect(async (userId: string) => {
const userData = await fetch(`/api/users/${userId}`);
return userData.name;
});
const $error = createStore<string | null>(null);
const $userName = createStore("");
const $isLoading = fetchUserNameFx.pending;
sample({
clock: fetchUserNameFx.doneData,
target: $userName,
});
sample({
clock: fetchUserNameFx.failData,
fn: (error) => error.message,
target: $error,
});

Error handling

An effect knows when an error occurs during execution and forwards it to the failData event. Sometimes we want to throw our own error instead of a plain Error instance:

import { createEffect } from "effector";
class CustomError extends Error {
// implementation
}
const effect = createEffect(async () => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
// You can throw errors that will be caught by the .failData handler
throw new CustomError(`Failed to fetch user: ${response.statusText}`);
}
return response.json();
});

The code above runs just fine, but if we subscribe to failData we will still see the type as Error rather than our CustomError:

sample({
clock: effect.failData,
// error is typed as Error, not CustomError
fn: (error) => error.message,
target: $error,
});

This happens because of typing: by default an effect assumes it will throw Error and has no way of knowing that we plan to throw CustomError. The solution is to pass the full type signature to the createEffect generic, including params, done type, and the error type:

import { createEffect } from "effector";
class CustomError extends Error {
// implementation
}
const effect = createEffect<Params, Done, CustomError>(async () => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new CustomError(`Failed to fetch user: ${response.statusText}`);
}
return response.json();
});

Read more about typing effects and other units on the TypeScript page.

Reusing effects

A common case is having a shared effect such as fetchShopCardsFx that you reuse on multiple screens while still wanting to subscribe to events like doneData or failData. If you subscribe directly to the shared effect, its listeners fire everywhere because effector units are declared statically at module level. The solution is attach, which creates a copy of the effect. You can then listen to that copy locally:

import { createEffect, attach, createEvent } from "effector";
const showNotification = createEvent();
// somewhere in shared code
const fetchShopCardsFx = createEffect(async () => {
const response = await fetch("/api/shop-cards");
return response.json();
});
// local copy that we can safely subscribe to
const fetchShopCardsAttachedFx = attach({
effect: fetchShopCardsFx,
});
sample({
clock: fetchShopCardsAttachedFx.failData,
target: showNotification,
});

When an attached effect created via attach runs, the original effect provided in effect runs as well.

Contributors