Effector Tutorial #1
Hello everyone, and welcome to the After Hours. This is the Effector Series, episode 2. In this episode we will be covering the basics of getting Effector added into a react project. All scripts and steps will be published on my blog, which will be linked in the description. We will be using Vite, but usually the bundler choice is not relevant as much to Effector ecosystem. More advanced subjects, such as integration with SSR might require some special consideration and they will be covered separately in future episodes. For the rest of this episode I'll assume that you're on a latest LTS version of node and we'll be using NPM. This should work with other package managers as well, but I'm not going to be covering the differences here.
Link to the Effector documentation: https://effector.dev/
Let's begin by getting the scaffolding going.
npm create vite@latest effector-app -- --template react-ts
cd effector-app
npm install
npm run dev
Excellent, we have an application with a counter implemented using hooks. So let's refactor it with using effector instead.
npm install effector{,-react}
npm install eslint-plugin-effector
Let's update the eslint
configuration. We will be using the recommended
and react
presets.
{
"plugins": ["effector"],
"extends": ["plugin:effector/recommended", "plugin:effector/react"]
}
We also need to add the required imports in our code.
import { createStore, createEvent } from "effector";
import { useUnit } from "effector-react";
Let's create our store that would hold the value for the counter.
const $counter = createStore(0);
Ok, so first thing you would notice, is that the store is created outside of the component. Second, is that there's a $
prefix, which is a suggested naming convention. The eslint
plugin would tell us if we are not naming our units according to it. Stores are prefixed with $
, Effects are postfixed with Fx
and events are simply verbs without any additional sigils. Let's see if we can observe the value of the store by watching it.
$counter.watch(current => console.log("Current value of $counter is", current));
This subscription will execute once the store settles. If we open the console we shall see the output.
Okay, now let's start doing the plumbing. First, we need to get the value of the store displayed on the page. For this purpose we shall be using the useUnit
hook from the effector-react
package. Because this is a hook, it has to reside within the component's scope.
const effectorCount = useUnit($count);
useUnit
is more flexible and allows to wire up multiple stores and events in one call, but we will look at the advanced use cases later.
If we look at the type of effectorCount
, we shall see that is is a number
, which makes sense, since the default value of the store – 0
is a number
.
We can now insert it into our template.
<div className="card">
<button>effectorCount is {effectorCount}</button>
</div>
We now see the button, and it does display “Effector count is 0” indeed. Now let's make it functional. Stores are able to react to events
. So, let's create an event.
const increment = createEvent();
We can actually watch an event as well. In effector ecosystem you can watch any unit
, and stores, events, and effects are all units.
increment.watch(() => console.log('Increment event was triggered'));
Now when we press the button we can see the message in the console.
Let's tie the store and event together with some business logic. There are a couple of ways we can do this, one is more universal, but a bit more complex, another is simple, but does not work for all scenarios.
The simple version looks like this
$count.on(increment, (currentValue) => currentValue + 1);
The more complex version is going to be leveraging one of the most commonly used functions in effector
, namely sample
.
import { sample } from "effector";
Let's look at how wiring it up with sample
is going to look like.
sample({
clock: increment,
source: $count,
fn(current) {
return current + 1;
},
target: $count
});
It reads as follows – “When increment
is triggered, take the current value from $count
, pass it through fn
, and send the result of that call to the $count
store”.
If you were to keep both variants of the code and press the button, the store would increment by 2 (however through the magic of effector, this causes only one re-render).
Now let's take this one step further, and extract all the logic code into it's own file to fully decouple it from the UI.
touch src/model.ts
Here's the contents of our model file
import { createStore, createEvent, sample } from "effector";
export const $count = createStore(0);
export const increment = createEvent();
sample({
clock: increment,
source: $count,
fn: current => current + 1,
target: $count
});
And we'll modify our component code to simply import all the necessary bits from the model.
import { $count, increment } from './model';
const { $count: effectorCount, increment: inc } = useUnit({ $count, increment });
But what if we want to re-use this logic for a separate counter? Well, effector does require to create all links between units ahead of time. It does not work well with dynamically created stores, though there are several developments on that front.
Right now the best way to encapsulate this is by creating a factory. A factory is simply a function that takes parameters and returns some units… In order to make it a bit easier for ourselves we can leverage the @with-ease/factores
library, that helps a bit with this. This library mostly helps in more advanced scenarios, that I will not be covering now, but we might get into the habit right away.
npm install @withease/factories
Let's rewrite our model code to leverage the factory pattern:
import { createStore, createEvent, sample } from "effector";
import { createFactory } from "@withease/factories";
export const counterFactory = createFactory(() => {
const $count = createStore(0);
const increment = createEvent();
sample({
clock: increment,
source: $count,
fn(current) {
return current + 1;
},
target: $count,
});
return { $count, increment };
});
And now let's modify the component
import { invoke } from "@withease/factories";
import { counterFactory } from "./model";
const counterOne = invoke(counterFactory);
And in the component body we'll make the following changes:
const { $count: effectorCount, increment: inc } = useUnit(counterOne);
Now, if we wanted to add another counter, we can do it trivially:
const counterTwo = invoke(counterFactory);
Now we can lift the counter itself into a separate component, that would take the counter business logic via props.
function Counter({ counter }: { counter: ReturnType<typeof counterFactory> }) {
const { $count, increment } = useUnit(counter);
return (
<div className="card">
<button onClick={() => increment()}>Current count is {$count}</button>
</div>
)
}
And now we use it
<Counter counter={counterOne} />
<Counter counter={counterTwo} />
Two fully independent counters backed by the same business logic.